// 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
}
}