Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CloudConvert.API/CloudConvert.API.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@

<ItemGroup>
<PackageReference Include="JetBrains.Annotations" Version="2021.1.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.1" />
<PackageReference Include="System.Net.Http" Version="4.3.4" />
</ItemGroup>

Expand Down
70 changes: 38 additions & 32 deletions CloudConvert.API/CloudConvertAPI.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using CloudConvert.API.Models;
using CloudConvert.API.Models.JobModels;
using CloudConvert.API.Models.TaskModels;
using CloudConvert.API.Models;
using System.Threading;

namespace CloudConvert.API
{
Expand Down Expand Up @@ -45,17 +45,17 @@ public class CloudConvertAPI : ICloudConvertAPI

readonly RestHelper _restHelper;
readonly string _api_key = "Bearer ";
const string sandboxUrlApi = "https://api.sandbox.cloudconvert.com/v2";
const string publicUrlApi = "https://api.cloudconvert.com/v2";
const string sandboxUrlSyncApi = "https://sync.api.sandbox.cloudconvert.com/v2";
const string publicUrlSyncApi = "https://sync.api.cloudconvert.com/v2";
private const string SandboxUrlApi = "https://api.sandbox.cloudconvert.com/v2";
private const string PublicUrlApi = "https://api.cloudconvert.com/v2";
private const string SandboxUrlSyncApi = "https://sync.api.sandbox.cloudconvert.com/v2";
private const string PublicUrlSyncApi = "https://sync.api.cloudconvert.com/v2";
static readonly char[] base64Padding = { '=' };

internal CloudConvertAPI(RestHelper restHelper, string api_key, bool isSandbox = false)
{
_apiUrl = isSandbox ? sandboxUrlApi : publicUrlApi;
_apiSyncUrl = isSandbox ? sandboxUrlSyncApi : publicUrlSyncApi;
_api_key += api_key;
_apiUrl = isSandbox ? SandboxUrlApi : PublicUrlApi;
_apiSyncUrl = isSandbox ? SandboxUrlSyncApi : PublicUrlSyncApi;
_api_key = $"Bearer {api_key}";
_restHelper = restHelper;
}

Expand All @@ -67,15 +67,15 @@ public CloudConvertAPI(string api_key, bool isSandbox = false)
public CloudConvertAPI(string url, string api_key)
{
_apiUrl = url;
_api_key += api_key;
_api_key = $"Bearer {api_key}";
_restHelper = new RestHelper();
}

private HttpRequestMessage GetRequest(string endpoint, HttpMethod method, object model = null)
{
var request = new HttpRequestMessage { RequestUri = new Uri(endpoint), Method = method };

if (model != null)
if (model is not null)
{
var content = new StringContent(JsonSerializer.Serialize(model, DefaultJsonSerializerOptions.SerializerOptions), Encoding.UTF8, "application/json");
request.Content = content;
Expand All @@ -93,7 +93,7 @@ private HttpRequestMessage GetMultipartFormDataRequest(string endpoint, HttpMeth
var content = new MultipartFormDataContent();
var request = new HttpRequestMessage { RequestUri = new Uri(endpoint), Method = method, };

if (parameters != null)
if (parameters is not null)
{
foreach (var param in parameters)
{
Expand All @@ -119,7 +119,7 @@ private HttpRequestMessage GetMultipartFormDataRequest(string endpoint, HttpMeth
/// <returns>
/// The list of jobs. You can find details about the job model response in the documentation about the show jobs endpoint.
/// </returns>
public Task<ListResponse<JobResponse>> GetAllJobsAsync(JobListFilter jobFilter, CancellationToken cancellationToken = default)
public Task<ListResponse<JobResponse>> GetAllJobsAsync(JobListFilter jobFilter, CancellationToken cancellationToken = default)
=> _restHelper.RequestAsync<ListResponse<JobResponse>>(GetRequest($"{_apiUrl}/jobs?filter[status]={jobFilter.Status}&filter[tag]={jobFilter.Tag}&include={jobFilter.Include}&per_page={jobFilter.PerPage}&page={jobFilter.Page}", HttpMethod.Get), cancellationToken);

/// <summary>
Expand All @@ -130,7 +130,7 @@ public Task<ListResponse<JobResponse>> GetAllJobsAsync(JobListFilter jobFilter,
/// <returns>
/// The created job. You can find details about the job model response in the documentation about the show jobs endpoint.
/// </returns>
public Task<Response<JobResponse>> CreateJobAsync(JobCreateRequest model, CancellationToken cancellationToken = default)
public Task<Response<JobResponse>> CreateJobAsync(JobCreateRequest model, CancellationToken cancellationToken = default)
=> _restHelper.RequestAsync<Response<JobResponse>>(GetRequest($"{_apiUrl}/jobs", HttpMethod.Post, model), cancellationToken);

/// <summary>
Expand Down Expand Up @@ -195,7 +195,7 @@ public Task<ListResponse<TaskResponse>> GetAllTasksAsync(TaskListFilter taskFilt
/// <returns>
/// The created task. You can find details about the task model response in the documentation about the show tasks endpoint.
/// </returns>
public Task<Response<TaskResponse>> CreateTaskAsync<T>(string operation, T model, CancellationToken cancellationToken = default)
public Task<Response<TaskResponse>> CreateTaskAsync<T>(string operation, T model, CancellationToken cancellationToken = default)
=> _restHelper.RequestAsync<Response<TaskResponse>>(GetRequest($"{_apiUrl}/{operation}", HttpMethod.Post, model), cancellationToken);

/// <summary>
Expand All @@ -205,7 +205,7 @@ public Task<Response<TaskResponse>> CreateTaskAsync<T>(string operation, T model
/// <param name="include"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public Task<Response<TaskResponse>> GetTaskAsync(string id, string include = null, CancellationToken cancellationToken = default)
public Task<Response<TaskResponse>> GetTaskAsync(string id, string include = null, CancellationToken cancellationToken = default)
=> _restHelper.RequestAsync<Response<TaskResponse>>(GetRequest($"{_apiUrl}/tasks/{id}?include={include}", HttpMethod.Get), cancellationToken);

/// <summary>
Expand All @@ -222,7 +222,7 @@ public Task<Response<TaskResponse>> GetTaskAsync(string id, string include = nul
/// <returns>
/// The finished or failed task. You can find details about the task model response in the documentation about the show tasks endpoint.
/// </returns>
public Task<Response<TaskResponse>> WaitTaskAsync(string id, CancellationToken cancellationToken = default)
public Task<Response<TaskResponse>> WaitTaskAsync(string id, CancellationToken cancellationToken = default)
=> _restHelper.RequestAsync<Response<TaskResponse>>(GetRequest($"{_apiSyncUrl}/tasks/{id}", HttpMethod.Get), cancellationToken);

/// <summary>
Expand All @@ -234,34 +234,38 @@ public Task<Response<TaskResponse>> WaitTaskAsync(string id, CancellationToken c
/// <returns>
/// An empty response with HTTP Code 204.
/// </returns>
public Task DeleteTaskAsync(string id, CancellationToken cancellationToken = default)
public Task DeleteTaskAsync(string id, CancellationToken cancellationToken = default)
=> _restHelper.RequestAsync<object>(GetRequest($"{_apiUrl}/tasks/{id}", HttpMethod.Delete), cancellationToken);

#endregion

public Task<string> UploadAsync(string url, byte[] file, string fileName, object parameters, CancellationToken cancellationToken)
public Task<string> UploadAsync(string url, byte[] file, string fileName, object parameters, CancellationToken cancellationToken)
=> _restHelper.RequestAsync(GetMultipartFormDataRequest(url, HttpMethod.Post, new ByteArrayContent(file), fileName, GetParameters(parameters)), cancellationToken);

public Task<string> UploadAsync(string url, Stream stream, string fileName, object parameters, CancellationToken cancellationToken = default)
=> _restHelper.RequestAsync(GetMultipartFormDataRequest(url, HttpMethod.Post, new StreamContent(stream), fileName, GetParameters(parameters)), cancellationToken);

public string CreateSignedUrl(string baseUrl, string signingSecret, JobCreateRequest job, string cacheKey = null)
{
string url = baseUrl;
string jobJson = JsonSerializer.Serialize(job, DefaultJsonSerializerOptions.SerializerOptions);
string base64Job = System.Convert.ToBase64String(Encoding.ASCII.GetBytes(jobJson)).TrimEnd(base64Padding).Replace('+', '-').Replace('/', '_');
var jobJson = JsonSerializer.Serialize(job, DefaultJsonSerializerOptions.SerializerOptions);
var base64Job = Convert.ToBase64String(Encoding.ASCII.GetBytes(jobJson))
.TrimEnd(base64Padding)
.Replace('+', '-')
.Replace('/', '_');

url += "?job=" + base64Job;
var builder = new StringBuilder(baseUrl)
.Append("?job=")
.Append(base64Job);

if(cacheKey != null) {
url += "&cache_key=" + cacheKey;
if (cacheKey is not null)
{
builder.Append("&cache_key=").Append(cacheKey);
}

string signature = HashHMAC(signingSecret, url);
var urlWithoutSignature = builder.ToString();
var signature = HashHMAC(signingSecret, urlWithoutSignature);

url += "&s=" + signature;

return url;
return builder.Append("&s=").Append(signature).ToString();
}

public bool ValidateWebhookSignatures(string payloadString, string signature, string signingSecret)
Expand All @@ -271,11 +275,13 @@ public bool ValidateWebhookSignatures(string payloadString, string signature, st
return hashHMAC == signature;
}

private string HashHMAC(string key, string message)
private static string HashHMAC(string key, string message)
{
byte[] hash = new HMACSHA256(Encoding.UTF8.GetBytes(key)).ComputeHash(new UTF8Encoding().GetBytes(message));
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(key));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(message));
return BitConverter.ToString(hash).Replace("-", "").ToLower();
}

private Dictionary<string, string> GetParameters(object parameters)
{
var dictionaryParameters = new Dictionary<string, string>();
Expand Down
20 changes: 20 additions & 0 deletions CloudConvert.API/Extensions/CloudConvertOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
namespace CloudConvert.API.Extensions
{
/// <summary>
/// Configuration options for the CloudConvert API client.
/// </summary>
public class CloudConvertOptions
{
/// <summary>
/// The CloudConvert API key used to authenticate requests.
/// </summary>
public string ApiKey { get; set; }

/// <summary>
/// Whether to use the CloudConvert sandbox environment.
/// Use <c>true</c> during development and testing to avoid consuming real credits.
/// Defaults to <c>false</c>.
/// </summary>
public bool IsSandbox { get; set; } = false;
}
}
59 changes: 59 additions & 0 deletions CloudConvert.API/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
using System;
using System.Net.Http;
using System.Threading;
using Microsoft.Extensions.DependencyInjection;

namespace CloudConvert.API.Extensions
{
/// <summary>
/// Extension methods for registering CloudConvert API services with the dependency injection container.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers the CloudConvert API client and its dependencies with the dependency injection container.
/// </summary>
/// <param name="services">The <see cref="IServiceCollection"/> to add the services to.</param>
/// <param name="configure">A delegate to configure the <see cref="CloudConvertOptions"/>.</param>
/// <returns>The <see cref="IServiceCollection"/> so that calls can be chained.</returns>
/// <exception cref="ArgumentException">Thrown when <see cref="CloudConvertOptions.ApiKey"/> is not provided.</exception>
/// <remarks>
/// Register the CloudConvert API client in your DI container:
/// <code>
/// services.AddCloudConvertAPI(options =>
/// {
/// options.ApiKey = "your_api_key";
/// options.IsSandbox = false;
/// });
/// </code>
/// </remarks>
public static IServiceCollection AddCloudConvertAPI(
this IServiceCollection services,
Action<CloudConvertOptions> configure)
{
var options = new CloudConvertOptions();
configure(options);

if (string.IsNullOrWhiteSpace(options.ApiKey))
{
throw new ArgumentException("ApiKey is required", nameof(configure));
}

services.AddHttpClient<ICloudConvertAPI, CloudConvertAPI>(client =>
{
client.Timeout = Timeout.InfiniteTimeSpan;
})
.ConfigurePrimaryHttpMessageHandler(() => new WebApiHandler());

services.AddSingleton<ICloudConvertAPI>(sp =>
{
var httpClient = sp.GetRequiredService<IHttpClientFactory>()
.CreateClient(nameof(CloudConvertAPI));

return new CloudConvertAPI(new RestHelper(httpClient), options.ApiKey, options.IsSandbox);
});

return services;
}
}
}
4 changes: 2 additions & 2 deletions CloudConvert.API/Extensions/StringExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public static string TrimLengthWithEllipsis([NotNull] this string str, int maxLe
return str;
}

return str.Substring(0, maxLength) + "...";
return string.Concat(str.AsSpan(0, maxLength), "...");
}

public static string GetEnumDescription<TEnum>(this TEnum source) where TEnum : struct, Enum
Expand All @@ -39,5 +39,5 @@ public static string GetEnumDescription<TEnum>(this TEnum source) where TEnum :
return attributes[0].Description;
}

}
}
}
8 changes: 4 additions & 4 deletions CloudConvert.API/RestHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public class RestHelper

internal RestHelper()
{
_httpClient = new HttpClient(new WebApiHandler(true));
_httpClient = new HttpClient(new WebApiHandler());
_httpClient.Timeout = System.TimeSpan.FromMilliseconds(System.Threading.Timeout.Infinite);
}

Expand All @@ -20,7 +20,7 @@ internal RestHelper(HttpClient httpClient)
_httpClient = httpClient;
}

public async Task<T> RequestAsync<T>(HttpRequestMessage request, CancellationToken cancellationToken)
internal async Task<T> RequestAsync<T>(HttpRequestMessage request, CancellationToken cancellationToken)
{
var response = await _httpClient.SendAsync(request, cancellationToken);
var responseRaw = await response.Content.ReadAsStringAsync(cancellationToken);
Expand All @@ -29,13 +29,13 @@ public async Task<T> RequestAsync<T>(HttpRequestMessage request, CancellationTok
// System.Text.Json throws when trying to deserialize an empty string
if (string.IsNullOrWhiteSpace(responseRaw) || response.StatusCode == System.Net.HttpStatusCode.NoContent)
{
return default(T);
return default;
}

return JsonSerializer.Deserialize<T>(responseRaw, DefaultJsonSerializerOptions.SerializerOptions);
}

public async Task<string> RequestAsync(HttpRequestMessage request, CancellationToken cancellationToken)
internal async Task<string> RequestAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var response = await _httpClient.SendAsync(request, cancellationToken);
return await response.Content.ReadAsStringAsync(cancellationToken);
Expand Down
23 changes: 1 addition & 22 deletions CloudConvert.API/WebApiHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,35 +6,14 @@

namespace CloudConvert.API
{
internal sealed class WebApiHandler : HttpClientHandler
internal sealed class WebApiHandler() : HttpClientHandler
{

private readonly bool _loggingEnabled;

public WebApiHandler(bool loggingEnabled)
{
_loggingEnabled = loggingEnabled;
}

protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{

bool writeLog = _loggingEnabled;

try
{
if (writeLog)
{
var requestString = request.Content != null ? await request.Content.ReadAsStringAsync(cancellationToken) : string.Empty;
}

var response = await base.SendAsync(request, cancellationToken);

if (writeLog)
{
string responseString = (await response.Content.ReadAsStringAsync(cancellationToken)).TrimLengthWithEllipsis(20000);
}

if ((int)response.StatusCode >= 400)
{
throw new WebApiException((await response.Content.ReadAsStringAsync(cancellationToken)).TrimLengthWithEllipsis(20000));
Expand Down
Loading
Loading