From d5e3de8e51c2074af0c5c2407dcad7601e7f3e62 Mon Sep 17 00:00:00 2001 From: Ivan Gechev Date: Thu, 12 Mar 2026 17:45:37 +0200 Subject: [PATCH] Various improvements --- CloudConvert.API/CloudConvert.API.csproj | 2 + CloudConvert.API/CloudConvertAPI.cs | 70 +++++---- .../Extensions/CloudConvertOptions.cs | 20 +++ .../Extensions/ServiceCollectionExtensions.cs | 59 +++++++ .../Extensions/StringExtensions.cs | 4 +- CloudConvert.API/RestHelper.cs | 8 +- CloudConvert.API/WebApiHandler.cs | 23 +-- .../ServiceCollectionExtensionsTests.cs | 67 ++++++++ README.md | 145 ++++++++++++------ 9 files changed, 289 insertions(+), 109 deletions(-) create mode 100644 CloudConvert.API/Extensions/CloudConvertOptions.cs create mode 100644 CloudConvert.API/Extensions/ServiceCollectionExtensions.cs create mode 100644 CloudConvert.Test/ServiceCollectionExtensionsTests.cs diff --git a/CloudConvert.API/CloudConvert.API.csproj b/CloudConvert.API/CloudConvert.API.csproj index fde5168..a784e57 100644 --- a/CloudConvert.API/CloudConvert.API.csproj +++ b/CloudConvert.API/CloudConvert.API.csproj @@ -18,6 +18,8 @@ + + diff --git a/CloudConvert.API/CloudConvertAPI.cs b/CloudConvert.API/CloudConvertAPI.cs index b329c9a..e6ef961 100644 --- a/CloudConvert.API/CloudConvertAPI.cs +++ b/CloudConvert.API/CloudConvertAPI.cs @@ -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 { @@ -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; } @@ -67,7 +67,7 @@ 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(); } @@ -75,7 +75,7 @@ private HttpRequestMessage GetRequest(string endpoint, HttpMethod method, object { 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; @@ -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) { @@ -119,7 +119,7 @@ private HttpRequestMessage GetMultipartFormDataRequest(string endpoint, HttpMeth /// /// The list of jobs. You can find details about the job model response in the documentation about the show jobs endpoint. /// - public Task> GetAllJobsAsync(JobListFilter jobFilter, CancellationToken cancellationToken = default) + public Task> GetAllJobsAsync(JobListFilter jobFilter, CancellationToken cancellationToken = default) => _restHelper.RequestAsync>(GetRequest($"{_apiUrl}/jobs?filter[status]={jobFilter.Status}&filter[tag]={jobFilter.Tag}&include={jobFilter.Include}&per_page={jobFilter.PerPage}&page={jobFilter.Page}", HttpMethod.Get), cancellationToken); /// @@ -130,7 +130,7 @@ public Task> GetAllJobsAsync(JobListFilter jobFilter, /// /// The created job. You can find details about the job model response in the documentation about the show jobs endpoint. /// - public Task> CreateJobAsync(JobCreateRequest model, CancellationToken cancellationToken = default) + public Task> CreateJobAsync(JobCreateRequest model, CancellationToken cancellationToken = default) => _restHelper.RequestAsync>(GetRequest($"{_apiUrl}/jobs", HttpMethod.Post, model), cancellationToken); /// @@ -195,7 +195,7 @@ public Task> GetAllTasksAsync(TaskListFilter taskFilt /// /// The created task. You can find details about the task model response in the documentation about the show tasks endpoint. /// - public Task> CreateTaskAsync(string operation, T model, CancellationToken cancellationToken = default) + public Task> CreateTaskAsync(string operation, T model, CancellationToken cancellationToken = default) => _restHelper.RequestAsync>(GetRequest($"{_apiUrl}/{operation}", HttpMethod.Post, model), cancellationToken); /// @@ -205,7 +205,7 @@ public Task> CreateTaskAsync(string operation, T model /// /// /// - public Task> GetTaskAsync(string id, string include = null, CancellationToken cancellationToken = default) + public Task> GetTaskAsync(string id, string include = null, CancellationToken cancellationToken = default) => _restHelper.RequestAsync>(GetRequest($"{_apiUrl}/tasks/{id}?include={include}", HttpMethod.Get), cancellationToken); /// @@ -222,7 +222,7 @@ public Task> GetTaskAsync(string id, string include = nul /// /// The finished or failed task. You can find details about the task model response in the documentation about the show tasks endpoint. /// - public Task> WaitTaskAsync(string id, CancellationToken cancellationToken = default) + public Task> WaitTaskAsync(string id, CancellationToken cancellationToken = default) => _restHelper.RequestAsync>(GetRequest($"{_apiSyncUrl}/tasks/{id}", HttpMethod.Get), cancellationToken); /// @@ -234,12 +234,12 @@ public Task> WaitTaskAsync(string id, CancellationToken c /// /// An empty response with HTTP Code 204. /// - public Task DeleteTaskAsync(string id, CancellationToken cancellationToken = default) + public Task DeleteTaskAsync(string id, CancellationToken cancellationToken = default) => _restHelper.RequestAsync(GetRequest($"{_apiUrl}/tasks/{id}", HttpMethod.Delete), cancellationToken); #endregion - public Task UploadAsync(string url, byte[] file, string fileName, object parameters, CancellationToken cancellationToken) + public Task 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 UploadAsync(string url, Stream stream, string fileName, object parameters, CancellationToken cancellationToken = default) @@ -247,21 +247,25 @@ public Task UploadAsync(string url, Stream stream, string fileName, obje 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) @@ -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 GetParameters(object parameters) { var dictionaryParameters = new Dictionary(); diff --git a/CloudConvert.API/Extensions/CloudConvertOptions.cs b/CloudConvert.API/Extensions/CloudConvertOptions.cs new file mode 100644 index 0000000..eb10898 --- /dev/null +++ b/CloudConvert.API/Extensions/CloudConvertOptions.cs @@ -0,0 +1,20 @@ +namespace CloudConvert.API.Extensions +{ + /// + /// Configuration options for the CloudConvert API client. + /// + public class CloudConvertOptions + { + /// + /// The CloudConvert API key used to authenticate requests. + /// + public string ApiKey { get; set; } + + /// + /// Whether to use the CloudConvert sandbox environment. + /// Use true during development and testing to avoid consuming real credits. + /// Defaults to false. + /// + public bool IsSandbox { get; set; } = false; + } +} diff --git a/CloudConvert.API/Extensions/ServiceCollectionExtensions.cs b/CloudConvert.API/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..f6e3e1a --- /dev/null +++ b/CloudConvert.API/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,59 @@ +using System; +using System.Net.Http; +using System.Threading; +using Microsoft.Extensions.DependencyInjection; + +namespace CloudConvert.API.Extensions +{ + /// + /// Extension methods for registering CloudConvert API services with the dependency injection container. + /// + public static class ServiceCollectionExtensions + { + /// + /// Registers the CloudConvert API client and its dependencies with the dependency injection container. + /// + /// The to add the services to. + /// A delegate to configure the . + /// The so that calls can be chained. + /// Thrown when is not provided. + /// + /// Register the CloudConvert API client in your DI container: + /// + /// services.AddCloudConvertAPI(options => + /// { + /// options.ApiKey = "your_api_key"; + /// options.IsSandbox = false; + /// }); + /// + /// + public static IServiceCollection AddCloudConvertAPI( + this IServiceCollection services, + Action configure) + { + var options = new CloudConvertOptions(); + configure(options); + + if (string.IsNullOrWhiteSpace(options.ApiKey)) + { + throw new ArgumentException("ApiKey is required", nameof(configure)); + } + + services.AddHttpClient(client => + { + client.Timeout = Timeout.InfiniteTimeSpan; + }) + .ConfigurePrimaryHttpMessageHandler(() => new WebApiHandler()); + + services.AddSingleton(sp => + { + var httpClient = sp.GetRequiredService() + .CreateClient(nameof(CloudConvertAPI)); + + return new CloudConvertAPI(new RestHelper(httpClient), options.ApiKey, options.IsSandbox); + }); + + return services; + } + } +} diff --git a/CloudConvert.API/Extensions/StringExtensions.cs b/CloudConvert.API/Extensions/StringExtensions.cs index be950bf..853a740 100644 --- a/CloudConvert.API/Extensions/StringExtensions.cs +++ b/CloudConvert.API/Extensions/StringExtensions.cs @@ -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(this TEnum source) where TEnum : struct, Enum @@ -39,5 +39,5 @@ public static string GetEnumDescription(this TEnum source) where TEnum : return attributes[0].Description; } - } + } } diff --git a/CloudConvert.API/RestHelper.cs b/CloudConvert.API/RestHelper.cs index 3ceeacd..675e15c 100644 --- a/CloudConvert.API/RestHelper.cs +++ b/CloudConvert.API/RestHelper.cs @@ -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); } @@ -20,7 +20,7 @@ internal RestHelper(HttpClient httpClient) _httpClient = httpClient; } - public async Task RequestAsync(HttpRequestMessage request, CancellationToken cancellationToken) + internal async Task RequestAsync(HttpRequestMessage request, CancellationToken cancellationToken) { var response = await _httpClient.SendAsync(request, cancellationToken); var responseRaw = await response.Content.ReadAsStringAsync(cancellationToken); @@ -29,13 +29,13 @@ public async Task RequestAsync(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(responseRaw, DefaultJsonSerializerOptions.SerializerOptions); } - public async Task RequestAsync(HttpRequestMessage request, CancellationToken cancellationToken) + internal async Task RequestAsync(HttpRequestMessage request, CancellationToken cancellationToken) { var response = await _httpClient.SendAsync(request, cancellationToken); return await response.Content.ReadAsStringAsync(cancellationToken); diff --git a/CloudConvert.API/WebApiHandler.cs b/CloudConvert.API/WebApiHandler.cs index 47cb0ca..b8096de 100644 --- a/CloudConvert.API/WebApiHandler.cs +++ b/CloudConvert.API/WebApiHandler.cs @@ -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 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)); diff --git a/CloudConvert.Test/ServiceCollectionExtensionsTests.cs b/CloudConvert.Test/ServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000..6967f0d --- /dev/null +++ b/CloudConvert.Test/ServiceCollectionExtensionsTests.cs @@ -0,0 +1,67 @@ +using System; +using CloudConvert.API; +using CloudConvert.API.Extensions; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; + +namespace CloudConvert.Test +{ + [TestFixture] + public class ServiceCollectionExtensionsTests + { + private ServiceProvider BuildProvider(Action configure) + { + var services = new ServiceCollection(); + services.AddCloudConvertAPI(configure); + return services.BuildServiceProvider(); + } + + [Test] + public void AddCloudConvertAPI_ValidApiKey_ResolvesICloudConvertAPI() + { + using var provider = BuildProvider(o => o.ApiKey = "test_key"); + + var api = provider.GetService(); + + Assert.IsNotNull(api); + } + + [Test] + public void AddCloudConvertAPI_ValidApiKey_ResolvesAsCloudConvertAPI() + { + using var provider = BuildProvider(o => o.ApiKey = "test_key"); + + var api = provider.GetService(); + + Assert.IsInstanceOf(api); + } + + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + public void AddCloudConvertAPI_MissingApiKey_ThrowsArgumentException(string apiKey) + { + Assert.Throws(() => + BuildProvider(o => o.ApiKey = apiKey)); + } + + [Test] + public void AddCloudConvertAPI_RegisteredAsSingleton_ReturnsSameInstance() + { + using var provider = BuildProvider(o => o.ApiKey = "test_key"); + + var first = provider.GetService(); + var second = provider.GetService(); + + Assert.AreSame(first, second); + } + + [Test] + public void AddCloudConvertAPI_DefaultOptions_IsSandboxIsFalse() + { + var options = new CloudConvertOptions(); + + Assert.IsFalse(options.IsSandbox); + } + } +} diff --git a/README.md b/README.md index 0a33edd..ffac2f7 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,55 @@ or dotnet add package CloudConvert.API ``` +## Dependency Injection + +If your application uses the .NET dependency injection container, you can register the CloudConvert API client using the `AddCloudConvertAPI` extension method: + +```CSharp +services.AddCloudConvertAPI(options => +{ + options.ApiKey = "your_api_key"; + options.IsSandbox = false; // defaults to false +}); +``` + +You can also bind options directly from your `appsettings.json`: + +```CSharp +services.AddCloudConvertAPI(options => + builder.Configuration.GetSection("CloudConvert").Bind(options)); +``` + +```json +{ + "CloudConvert": { + "ApiKey": "your_api_key", + "IsSandbox": false + } +} +``` + +Then inject `ICloudConvertAPI` wherever you need it: + +```CSharp +public class MyService(ICloudConvertAPI cloudConvert) +{ + public async Task DoSomething(CancellationToken cancellationToken) + { + var job = await cloudConvert.CreateJobAsync(..., cancellationToken); + } +} +``` + +If you are not using dependency injection, you can instantiate the client directly: + +```CSharp +var cloudConvert = new CloudConvertAPI("your_api_key"); +``` + ## Creating Jobs -```c# +```CSharp using CloudConvert.API; using CloudConvert.API.Models.ExportOperations; using CloudConvert.API.Models.ImportOperations; @@ -27,24 +73,24 @@ using CloudConvert.API.Models.TaskOperations; var _cloudConvert = new CloudConvertAPI("api_key"); var job = await _cloudConvert.CreateJobAsync(new JobCreateRequest - { - Tasks = new - { - import_example_1 = new ImportUploadCreateRequest(), - convert = new ConvertCreateRequest - { - Input = "import_example_1", - Input_Format = "pdf", - Output_Format = "docx" - }, - export = new ExportUrlCreateRequest - { - Input = "convert", - Archive_Multiple_Files = true - } - }, - Tag = "Test" - }); +{ + Tasks = new + { + import_example_1 = new ImportUploadCreateRequest(), + convert = new ConvertCreateRequest + { + Input = "import_example_1", + Input_Format = "pdf", + Output_Format = "docx" + }, + export = new ExportUrlCreateRequest + { + Input = "convert", + Archive_Multiple_Files = true + } + }, + Tag = "Test" +}); ``` You can use the [CloudConvert Job Builder](https://cloudconvert.com/api/v2/jobs/builder) to see the available options for the various task types. @@ -53,7 +99,7 @@ You can use the [CloudConvert Job Builder](https://cloudconvert.com/api/v2/jobs/ CloudConvert can generate public URLs for using `export/url` tasks. You can use these URLs to download output files. -```c# +```CSharp var job = await _cloudConvertAPI.WaitJobAsync(job.Data.Id); // Wait for job completion // download export file @@ -70,15 +116,15 @@ Uploads to CloudConvert are done via `import/upload` tasks (see the [docs](https First create the upload job with `CreateJobAsync`: -```c# +```CSharp var job = await _cloudConvertAPI.CreateJobAsync(new JobCreateRequest - { - Tasks = new - { - upload_my_file = new ImportUploadCreateRequest() - // ... - } - }); +{ + Tasks = new + { + upload_my_file = new ImportUploadCreateRequest() + // ... + } +}); var uploadTask = job.Data.Tasks.FirstOrDefault(t => t.Name == "upload_my_file"); ``` @@ -96,7 +142,7 @@ Then upload the file the file with `UploadAsync`. This can be done two ways: using (System.IO.Stream stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read)) { - await _cloudConvertAPI.UploadAsync(uploadTask.Result.Form.Url.ToString(), stream, fileName, uploadTask.Result.Form.Parameters); + await _cloudConvertAPI.UploadAsync(uploadTask.Result.Form.Url.ToString(), stream, fileName, uploadTask.Result.Form.Parameters); } ``` @@ -114,13 +160,14 @@ Then upload the file the file with `UploadAsync`. This can be done two ways: You can pass any custom options to the task payload via the special `Options` property: -```c# +```CSharp var task = new ConvertCreateRequest { Input = "import_example_1", Input_Format = "pdf", Output_Format = "jpg", - Options = new Dictionary { + Options = new Dictionary + { { "width", 800 }, { "height", 600 }, { "fit", "max" } @@ -133,7 +180,7 @@ You can use the [Job Builder](https://cloudconvert.com/api/v2/jobs/builder) to s The .net SDK allows to verify webhook requests received from CloudConvert. -```c# +```CSharp var payloadString = "..."; // The JSON string from the raw request body. var signature = "..."; // The value of the "CloudConvert-Signature" header. var signingSecret = "..."; // You can find it in your webhook settings. @@ -146,27 +193,27 @@ var isValid = _cloudConvertAPI.ValidateWebhookSignatures(payloadString, signatur Signed URLs allow converting files on demand only using URL query parameters. The .NET SDK allows to generate such URLs. Therefore, you need to obtain a signed URL base and a signing secret on the [CloudConvert Dashboard](https://cloudconvert.com/dashboard/api/v2/signed-urls). -```c# +```CSharp var signedUrlBase = 'https://s.cloudconvert.com/...'; // You can find it in your signed URL settings. var signingSecret = '...'; // You can find it in your signed URL settings. var cacheKey = 'cache-key'; // Allows caching of the result file for 24h var job = new JobCreateRequest +{ + Tasks = new + { + import_example_1 = new ImportUploadCreateRequest(), + convert = new ConvertCreateRequest + { + Input = "import_example_1", + Input_Format = "pdf", + Output_Format = "docx" + }, + export = new ExportUrlCreateRequest { - Tasks = new - { - import_example_1 = new ImportUploadCreateRequest(), - convert = new ConvertCreateRequest - { - Input = "import_example_1", - Input_Format = "pdf", - Output_Format = "docx" - }, - export = new ExportUrlCreateRequest - { - Input = "convert" - } - }, + Input = "convert" + } + } }; string signedUrl = _cloudConvertAPI.CreateSignedUrl(baseUrl, signingSecret, job, cacheKey) @@ -177,7 +224,7 @@ string signedUrl = _cloudConvertAPI.CreateSignedUrl(baseUrl, signingSecret, job, You can use the Sandbox to avoid consuming your quota while testing your application. The .net SDK allows you to do that. -```c# +```CSharp // Pass `true` to the constructor var _cloudConvert = new CloudConvertAPI("api_key", true); ``` @@ -198,4 +245,4 @@ By default, this runs the integration tests against the Sandbox API with an offi ## Resources - [API v2 Documentation](https://cloudconvert.com/api/v2) -- [CloudConvert Blog](https://cloudconvert.com/blog) +- [CloudConvert Blog](https://cloudconvert.com/blog) \ No newline at end of file