// Copyright Epic Games, Inc. All Rights Reserved. using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using EpicGames.Core; using EpicGames.Horde.Agents.Telemetry; using EpicGames.Horde.Artifacts; using EpicGames.Horde.Commits; using EpicGames.Horde.Dashboard; using EpicGames.Horde.Devices; using EpicGames.Horde.Jobs; using EpicGames.Horde.Jobs.Graphs; using EpicGames.Horde.Logs; using EpicGames.Horde.Projects; using EpicGames.Horde.Secrets; using EpicGames.Horde.Server; using EpicGames.Horde.Storage; using EpicGames.Horde.Streams; using EpicGames.Horde.Tools; using EpicGames.Horde.Ugs; using static EpicGames.Horde.HordeHttpRequest; #pragma warning disable CA2234 namespace EpicGames.Horde { using JsonObject = System.Text.Json.Nodes.JsonObject; /// /// Wraps an Http client which communicates with the Horde server /// public sealed class HordeHttpClient : IDisposable { /// /// Name of an environment variable containing the Horde server URL /// public const string HordeUrlEnvVarName = "UE_HORDE_URL"; /// /// Name of an environment variable containing a token for connecting to the Horde server /// public const string HordeTokenEnvVarName = "UE_HORDE_TOKEN"; /// /// Name of clients created from the http client factory /// public const string HttpClientName = "HordeHttpClient"; /// /// Name of clients used for anonymous requests. /// public const string AnonymousHttpClientName = "HordeAnonymousHttpClient"; /// /// Name of clients created for storage operations /// public const string StorageHttpClientName = "HordeStorageHttpClient"; /// /// Name of clients created from the http client factory for handling upload redirects. Should not contain Horde auth headers. /// public const string UploadRedirectHttpClientName = "HordeUploadRedirectHttpClient"; /// /// Accessor for the inner http client /// public HttpClient HttpClient => _httpClient; readonly HttpClient _httpClient; internal static JsonSerializerOptions JsonSerializerOptions => HordeHttpRequest.JsonSerializerOptions; /// /// Base address for the Horde server /// public Uri BaseUrl => _httpClient.BaseAddress ?? throw new InvalidOperationException("Expected Horde server base address to be configured"); /// /// Constructor /// /// The inner HTTP client instance public HordeHttpClient(HttpClient httpClient) { _httpClient = httpClient; } /// public void Dispose() { _httpClient.Dispose(); } /// /// Configures a JSON serializer to read Horde responses /// /// options for the serializer public static void ConfigureJsonSerializer(JsonSerializerOptions options) => HordeHttpRequest.ConfigureJsonSerializer(options); #region Connection /// /// Check account login status. /// public async Task CheckConnectionAsync(CancellationToken cancellationToken = default) { HttpResponseMessage response = await _httpClient.GetAsync("account", cancellationToken); return response.IsSuccessStatusCode; } #endregion #region Artifacts /// /// Creates a new artifact /// /// Name of the artifact /// Additional search keys tagged on the artifact /// Description for the artifact /// Stream to create the artifact for /// Commit for the artifact /// Keys used to identify the artifact /// Metadata for the artifact /// Cancellation token for the operation public Task CreateArtifactAsync(ArtifactName name, ArtifactType type, string? description, StreamId streamId, CommitId commitId, IEnumerable? keys = null, IEnumerable? metadata = null, CancellationToken cancellationToken = default) { return PostAsync(_httpClient, $"api/v2/artifacts", new CreateArtifactRequest(name, type, description, streamId, keys?.ToList() ?? new List(), metadata?.ToList() ?? new List()) { CommitId = commitId }, cancellationToken); } /// /// Deletes an artifact /// /// Identifier for the artifact /// Cancellation token for the operation public async Task DeleteArtifactAsync(ArtifactId id, CancellationToken cancellationToken = default) { await DeleteAsync(_httpClient, $"api/v2/artifacts/{id}", cancellationToken); } /// /// Gets metadata about an artifact object /// /// Identifier for the artifact /// Cancellation token for the operation public Task GetArtifactAsync(ArtifactId id, CancellationToken cancellationToken = default) { return GetAsync(_httpClient, $"api/v2/artifacts/{id}", cancellationToken); } /// /// Gets a zip stream for a particular artifact /// /// Identifier for the artifact /// Cancellation token for the operation public async Task GetArtifactZipAsync(ArtifactId id, CancellationToken cancellationToken = default) { return await _httpClient.GetStreamAsync($"api/v2/artifacts/{id}/zip", cancellationToken); } /// /// Finds artifacts with a certain type with an optional streamId /// /// Stream to look for the artifact in /// The minimum change number for the artifacts /// The minimum change number for the artifacts /// Name of the artifact /// Type to find /// Keys for artifacts to return /// Maximum number of results to return /// Cancellation token for the operation /// Information about all the artifacts public Task> FindArtifactsAsync(StreamId? streamId = null, CommitId? minCommitId = null, CommitId? maxCommitId = null, ArtifactName? name = null, ArtifactType? type = null, IEnumerable? keys = null, int maxResults = 100, CancellationToken cancellationToken = default) => FindArtifactsAsync(null, streamId, minCommitId, maxCommitId, name, type, keys, maxResults, cancellationToken); /// /// Finds artifacts with a certain type with an optional streamId /// /// Identifiers to return /// Stream to look for the artifact in /// The minimum change number for the artifacts /// The minimum change number for the artifacts /// Name of the artifact /// Type to find /// Keys for artifacts to return /// Maximum number of results to return /// Cancellation token for the operation /// Information about all the artifacts public async Task> FindArtifactsAsync(IEnumerable? ids, StreamId? streamId = null, CommitId? minCommitId = null, CommitId? maxCommitId = null, ArtifactName? name = null, ArtifactType? type = null, IEnumerable? keys = null, int maxResults = 100, CancellationToken cancellationToken = default) { QueryStringBuilder queryParams = new QueryStringBuilder(); if (ids != null) { queryParams.Add("id", ids.Select(x => x.ToString())); } if (streamId != null) { queryParams.Add("streamId", streamId.ToString()!); } if (minCommitId != null) { queryParams.Add("minChange", minCommitId.ToString()!); } if (maxCommitId != null) { queryParams.Add("maxChange", maxCommitId.ToString()!); } if (name != null) { queryParams.Add("name", name.Value.ToString()); } if (type != null) { queryParams.Add("type", type.Value.ToString()); } if (keys != null) { foreach (string key in keys) { queryParams.Add("key", key); } } queryParams.Add("maxResults", maxResults.ToString()); FindArtifactsResponse response = await GetAsync(_httpClient, $"api/v2/artifacts?{queryParams}", cancellationToken); return response.Artifacts; } #endregion #region Dashboard /// /// Create a new dashboard preview item /// /// Request to create a new preview item /// Cancellation token for the operation /// Config information needed by the dashboard public Task CreateDashbordPreviewAsync(CreateDashboardPreviewRequest request, CancellationToken cancellationToken = default) { return PostAsync(_httpClient, $"api/v1/dashboard/preview", request, cancellationToken); } /// /// Update a dashboard preview item /// /// Config information needed by the dashboard public Task UpdateDashbordPreviewAsync(UpdateDashboardPreviewRequest request, CancellationToken cancellationToken = default) { return PutAsync(_httpClient, $"api/v1/dashboard/preview", request, cancellationToken); } /// /// Query dashboard preview items /// /// Config information needed by the dashboard public Task> GetDashbordPreviewsAsync(bool open = true, CancellationToken cancellationToken = default) { return GetAsync>(_httpClient, $"api/v1/dashboard/preview?open={open}", cancellationToken); } #endregion #region Parameters /// /// Query parameters for other tools /// /// Cancellation token for the operation /// Parameters for other tools public Task GetParametersAsync(CancellationToken cancellationToken = default) { return GetParametersAsync(null, cancellationToken); } /// /// Query parameters for other tools /// /// Path for properties to return /// Cancellation token for the operation /// Information about all the projects public Task GetParametersAsync(string? path, CancellationToken cancellationToken = default) { string url = "api/v1/parameters"; if (!String.IsNullOrEmpty(path)) { url = $"{url}/{path}"; } return GetAsync(_httpClient, url, cancellationToken); } #endregion #region Projects /// /// Query all the projects /// /// Whether to include streams in the response /// Whether to include categories in the response /// Cancellation token for the operation /// Information about all the projects public Task> GetProjectsAsync(bool includeStreams = false, bool includeCategories = false, CancellationToken cancellationToken = default) { return GetAsync>(_httpClient, $"api/v1/projects?includeStreams={includeStreams}&includeCategories={includeCategories}", cancellationToken); } /// /// Retrieve information about a specific project /// /// Id of the project to get information about /// Cancellation token for the operation /// Information about the requested project public Task GetProjectAsync(ProjectId projectId, CancellationToken cancellationToken = default) { return GetAsync(_httpClient, $"api/v1/projects/{projectId}", cancellationToken); } #endregion #region Secrets /// /// Query all the secrets available to the current user /// /// Cancellation token for the operation /// Information about all the projects public Task GetSecretsAsync(CancellationToken cancellationToken = default) { return GetAsync(_httpClient, $"api/v1/secrets", cancellationToken); } /// /// Retrieve information about a specific secret /// /// Id of the secret to retrieve /// Cancellation token for the operation /// Information about the requested secret public Task GetSecretAsync(SecretId secretId, CancellationToken cancellationToken = default) { return GetAsync(_httpClient, $"api/v1/secrets/{secretId}", cancellationToken); } /// /// Retrieve information about a specific secret and property /// /// A string representation of a secret to retrieve. /// A string that contains the "horde:secret:" prefix followed by secret id and property name e.g. 'horde:secret:my-secret.property' /// Cancellation token for the operation /// Information about the requested secret public Task ResolveSecretAsync(string value, CancellationToken cancellationToken = default) { return GetAsync(_httpClient, $"api/v1/secrets/resolve/{value}", cancellationToken); } #endregion #region Server /// /// Gets information about the currently deployed server version /// /// Cancellation token for the operation /// Information about the deployed server instance public Task GetServerInfoAsync(CancellationToken cancellationToken = default) { return GetAsync(_httpClient, "api/v1/server/info", cancellationToken); } #endregion #region Storage /// /// Attempts to read a named storage ref from the server /// /// Path to the ref /// Max allowed age for a cached value to be returned /// Cancellation token for the operation public async Task TryReadRefAsync(string path, RefCacheTime cacheTime = default, CancellationToken cancellationToken = default) { using (HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, path)) { if (cacheTime.IsSet()) { request.Headers.CacheControl = new CacheControlHeaderValue { MaxAge = cacheTime.MaxAge }; } using (HttpResponseMessage response = await _httpClient.SendAsync(request, cancellationToken)) { if (response.StatusCode == HttpStatusCode.NotFound) { return null; } else if (!response.IsSuccessStatusCode) { throw new StorageException($"Unable to read ref '{path}' (status: {response.StatusCode}, body: {await response.Content.ReadAsStringAsync(cancellationToken)}"); } else { return await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); } } } } #endregion #region Telemetry /// /// Gets telemetry for Horde within a given range /// /// End date for the range /// Number of hours to return /// Timezone offset /// Cancellation token for the operation public Task> GetTelemetryAsync(DateTime endDate, int range, int? tzOffset = null, CancellationToken cancellationToken = default) { QueryStringBuilder queryParams = new QueryStringBuilder(); queryParams.Add("Range", range.ToString()); if (tzOffset != null) { queryParams.Add("TzOffset", tzOffset.Value.ToString()); } return GetAsync>(_httpClient, $"api/v1/reports/utilization/{endDate}?{queryParams}", cancellationToken); } #endregion #region Tools /// /// Enumerates all the available tools. /// public Task GetToolsAsync(CancellationToken cancellationToken = default) { return GetAsync(_httpClient, "api/v1/tools", cancellationToken); } /// /// Gets information about a particular tool /// public Task GetToolAsync(ToolId id, CancellationToken cancellationToken = default) { return GetAsync(_httpClient, $"api/v1/tools/{id}", cancellationToken); } /// /// Gets information about a particular deployment /// public Task GetToolDeploymentAsync(ToolId id, ToolDeploymentId deploymentId, CancellationToken cancellationToken = default) { return GetAsync(_httpClient, $"api/v1/tools/{id}/deployments/{deploymentId}", cancellationToken); } /// /// Gets a zip stream for a particular deployment /// public async Task GetToolDeploymentZipAsync(ToolId id, ToolDeploymentId? deploymentId, CancellationToken cancellationToken = default) { if (deploymentId == null) { return await _httpClient.GetStreamAsync($"api/v1/tools/{id}?action=zip", cancellationToken); } else { return await _httpClient.GetStreamAsync($"api/v1/tools/{id}/deployments/{deploymentId}?action=zip", cancellationToken); } } /// /// Creates a new tool deployment /// /// Id for the tool /// Version string for the new deployment /// Duration over which to deploy the tool /// Whether to create the deployment, but do not start rolling it out yet /// Location of a directory node describing the deployment /// Cancellation token for the operation public async Task CreateToolDeploymentAsync(ToolId id, string? version, double? duration, bool? createPaused, HashedBlobRefValue target, CancellationToken cancellationToken = default) { CreateToolDeploymentRequest request = new CreateToolDeploymentRequest(version ?? String.Empty, duration, createPaused, target); CreateToolDeploymentResponse response = await PostAsync(_httpClient, $"api/v2/tools/{id}/deployments", request, cancellationToken); return response.Id; } #endregion #region Jobs /// /// Gets job information for given job ID. Fail response if jobID does not exist. /// /// Id of the job to get infomation for /// Cancellation token for the operation /// public Task GetJobAsync(JobId id, CancellationToken cancellationToken = default) { return GetAsync(_httpClient, $"api/v1/jobs/{id}", cancellationToken); } /// /// Apply metadata tags to jobs and steps /// /// /// /// /// /// public Task PutJobMetadataAsync(JobId id, IEnumerable? jobMetaData = null, Dictionary>? stepMetaData = null, CancellationToken cancellationToken = default) { return PutAsync(_httpClient, $"api/v1/jobs/{id}/metadata", new PutJobMetadataRequest() {JobMetaData = jobMetaData?.ToList(), StepMetaData = stepMetaData}, cancellationToken); } #endregion #region Log /// /// Get the given log file /// /// Id of the log file to retrieve /// Text to search for in the log /// Number of lines to return (default 5) /// Cancellation token for the operation /// public Task GetSearchLogAsync(LogId logId, string searchText, int count = 5, CancellationToken cancellationToken = default) { return GetAsync(_httpClient, $"/api/v1/logs/{logId}/search?Text={Uri.EscapeDataString(searchText)}&count={count}", cancellationToken); } /// /// Get the requested number of lines from given logFileId, starting at index /// /// Id of log file to retrieve lines from /// Start index of lines to retrieve /// Number of lines to retrieve /// Cancellation token for the operation /// public Task GetLogLinesAsync(LogId logId, int startIndex, int count, CancellationToken cancellationToken = default) { return GetAsync(_httpClient, $"/api/v1/logs/{logId}/lines?index={startIndex}&count={count}", cancellationToken); } #endregion #region Graph /// /// Get graph of the given job /// /// /// /// Contains buildgraph information for the job public Task GetGraphAsync(JobId jobId, CancellationToken cancellationToken = default) { return GetAsync(_httpClient, $"/api/v1/jobs/{jobId}/graph", cancellationToken); } #endregion #region UGS /// /// /// /// /// /// /// /// public Task GetUgsMetadataAsync(StreamId streamId, CommitId commitId, ProjectId projectId, CancellationToken cancellationToken = default) { Regex streamRe = new Regex(@"(.*?)\-(.*)"); Match streamMatch = streamRe.Match(streamId.ToString()); if (streamMatch.Success) { string streamName = $"//{streamMatch.Groups[1]}/{streamMatch.Groups[2]}"; return GetAsync(_httpClient, $"/ugs/api/metadata?stream={streamName}&change={commitId.GetPerforceChange()}&project={projectId}", cancellationToken); } else { throw new ArgumentException($"StreamId '{streamId.ToString()} is invalid'"); } } #endregion #region Devices /// /// Retrieves information about all devices /// /// Cancellation token for the operation /// Collection of GetDeviceResponse containing information about all devices public Task> GetDevicesAsync(CancellationToken cancellationToken = default) { return GetAsync>(_httpClient, $"/api/v2/devices", cancellationToken); } /// /// Retrieves information about the specified device /// /// Id of the device to query /// Cancellation token for the operation /// GetDeviceResponse containing information about the device public Task GetDeviceAsync(string deviceId, CancellationToken cancellationToken = default) { return GetAsync(_httpClient, $"/api/v2/devices/{deviceId}", cancellationToken); } /// /// Updates an individual device with the requested fields /// /// Id of the device to update /// Request object containing the fields to update /// Cancellation token for the operation /// The response message from the http request public Task PutDeviceUpdateAsync(string deviceId, UpdateDeviceRequest request, CancellationToken cancellationToken = default) { return PutAsync(_httpClient, $"/api/v2/devices/{deviceId}", request, cancellationToken); } #endregion } }