diff --git a/CHANGELOG.md b/CHANGELOG.md index 89645e5..0859595 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ The format is based on Keep a Changelog and this project adheres to Semantic Ver ## [Unreleased] +### Added +- When no token is set in code, the SDK reads it from the `CONVERTAPI_TOKEN` environment variable, so + `new ConvertApi()` works without a token in the source. A token set in code (`new ConvertApi("...")` or + `ConvertApi.ApiToken`) always comes first. The variable is read at each request. +- With no token in code or in the variable, uploads, `ConvertAsync` and `GetUserAsync` throw a + `ConvertApiException` with status 401 that says how to set the token, before anything is sent. Earlier + versions uploaded the input files first and then got a 401 from the API. +- `new ConvertApi("")` and `new ConvertApi(null)` still throw `ArgumentNullException`; its message now names + `CONVERTAPI_TOKEN`. + ### Fixed - `SaveFileAsync` and `SaveFilesAsync` replace an existing file instead of writing over its start. They opened it with `FileInfo.OpenWrite`, which does not truncate, so a result saved over a longer file kept diff --git a/ConvertApi.Tests/TokenTests.cs b/ConvertApi.Tests/TokenTests.cs new file mode 100644 index 0000000..f35272c --- /dev/null +++ b/ConvertApi.Tests/TokenTests.cs @@ -0,0 +1,155 @@ +using System.Net; +using System.Text; +using ConvertApiDotNet.Exceptions; +using ConvertApiDotNet.Interface; + +namespace ConvertApiDotNet.Tests; + +// Which token a request carries: the one set in code, else the CONVERTAPI_TOKEN environment variable, else none, +// in which case nothing is sent. The tests change the static ConvertApi.ApiToken, the static client and the +// process environment, so they restore all three and must not run in parallel with other tests. +[NonParallelizable] +public class TokenTests +{ + private const string Variable = "CONVERTAPI_TOKEN"; + + private string? _savedToken; + private IConvertApiHttpClient? _savedClient; + private string? _savedVariable; + private string _dir = ""; + private RecordingServer _server = null!; + + [SetUp] + public void SetUp() + { + _savedToken = ConvertApi.ApiToken; + _savedClient = ConvertApi.GetClient(); + _savedVariable = Environment.GetEnvironmentVariable(Variable); + Environment.SetEnvironmentVariable(Variable, null); + + _dir = Path.Combine(Path.GetTempPath(), "ConvertApi.Tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_dir); + File.WriteAllText(Path.Combine(_dir, "document.docx"), "not really a docx"); + + _server = new RecordingServer(); + // Installs the fake client with no token set in code. + _ = new ConvertApi(null!, new FakeClient(_server)); + } + + [TearDown] + public void TearDown() + { + _ = new ConvertApi(_savedToken!, _savedClient!); + Environment.SetEnvironmentVariable(Variable, _savedVariable); + Directory.Delete(_dir, recursive: true); + _server.Dispose(); + } + + [Test] + public async Task TheTokenSetInCodeWinsOverTheVariable() + { + Environment.SetEnvironmentVariable(Variable, "token-from-variable"); + var convertApi = new ConvertApi("token-from-code"); + + await convertApi.ConvertAsync("docx", "pdf", new ConvertApiFileParam(Path.Combine(_dir, "document.docx"))); + + Assert.That(_server.Tokens, Is.EqualTo(new[] { "token-from-code", "token-from-code" })); + } + + [Test] + public async Task WithNoTokenInCodeTheVariableIsUsed() + { + Environment.SetEnvironmentVariable(Variable, " token-from-variable\n"); + var convertApi = new ConvertApi(); + + await convertApi.ConvertAsync("docx", "pdf", new ConvertApiFileParam(Path.Combine(_dir, "document.docx"))); + await convertApi.GetUserAsync(); + + Assert.That(_server.Tokens, Is.EqualTo(new[] { "token-from-variable", "token-from-variable", "token-from-variable" })); + } + + [Test] + public async Task WithNoTokenAtAllNothingIsSent() + { + var convertApi = new ConvertApi(); + + var error = await Assert.ThrowsAsync(() => + convertApi.ConvertAsync("docx", "pdf", new ConvertApiFileParam(Path.Combine(_dir, "document.docx")))); + + using (Assert.EnterMultipleScope()) + { + // Code that already handled the API's 401 for a missing token keeps handling this one. + Assert.That(error!.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); + Assert.That(error.Message, Does.Contain("CONVERTAPI_TOKEN")); + Assert.That(error.Response, Is.EqualTo(error.Message)); + Assert.That(_server.Tokens, Is.Empty); + } + } + + [Test] + public async Task WithNoTokenAtAllASuppliedStreamIsStillDisposed() + { + var stream = new MemoryStream(Encoding.UTF8.GetBytes("not really a docx")); + + await Assert.ThrowsAsync(() => + new ConvertApi().ConvertAsync("docx", "pdf", new ConvertApiFileParam(stream, "document.docx"))); + + using (Assert.EnterMultipleScope()) + { + Assert.That(stream.CanRead, Is.False, "the stream should be disposed"); + Assert.That(_server.Tokens, Is.Empty); + } + } + + [Test] + public async Task WithNoTokenAtAllGetUserAsyncSendsNothing() + { + var error = await Assert.ThrowsAsync(() => new ConvertApi().GetUserAsync()); + + using (Assert.EnterMultipleScope()) + { + Assert.That(error!.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); + Assert.That(_server.Tokens, Is.Empty); + } + } + + [Test] + public void AnEmptyTokenPassedInCodeNamesTheVariable() + { + var error = Assert.Throws(() => _ = new ConvertApi("")); + + using (Assert.EnterMultipleScope()) + { + Assert.That(error!.ParamName, Is.EqualTo("apiToken")); + Assert.That(error.Message, Does.Contain("CONVERTAPI_TOKEN")); + } + } + + private sealed class FakeClient(HttpMessageHandler handler) : IConvertApiHttpClient + { + public HttpClient Client { get; } = new(handler); + } + + // Answers the upload, convert and user requests the way the API does, and records each request's token. + private sealed class RecordingServer : HttpMessageHandler + { + public List Tokens { get; } = new(); + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Tokens.Add(request.Headers.Authorization?.Parameter); + var path = request.RequestUri!.AbsolutePath; + var json = path switch + { + "/upload" => """{"FileId":"0123456789abcdef0123456789abcdef","FileName":"document.docx","FileExt":"docx"}""", + "/convert/docx/to/pdf" => """{"ConversionCost":1,"Files":[{"FileName":"document.pdf","FileExt":"pdf","FileSize":3,"Url":"https://v2.convertapi.com/d/test/document.pdf"}]}""", + "/user" => """{"Active":true}""", + _ => throw new InvalidOperationException($"Unexpected request: {request.Method} {request.RequestUri}"), + }; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json"), + }); + } + } +} diff --git a/ConvertApi/ConvertApi.cs b/ConvertApi/ConvertApi.cs index 22060e7..bc05c5c 100644 --- a/ConvertApi/ConvertApi.cs +++ b/ConvertApi/ConvertApi.cs @@ -18,19 +18,35 @@ namespace ConvertApiDotNet { public class ConvertApi { + /// + /// The name of the environment variable the SDK reads the API token from when no token is set in code. + /// + public const string TokenEnvironmentVariable = "CONVERTAPI_TOKEN"; + + /// + /// The API token set in code. When it is null or empty, the SDK reads the token from the + /// CONVERTAPI_TOKEN environment variable at each request. + /// public static string ApiToken; public static string ApiBaseUri = "https://v2.convertapi.com"; private static IConvertApiHttpClient _convertApiHttpClient; + // Where a user finds or gets a token. Tagged so the website can count the visits this message sends. + private const string TokenUrl = "https://www.convertapi.com/a/signup?ReturnUrl=%2Fa%2Fauthentication&utm_source=dotnet-sdk&utm_medium=referral&utm_campaign=dotnet-sdk&utm_content=error-no-token"; + + /// + /// Initializes a new instance of the ConvertApi class that uses the token set in , or + /// else the token in the CONVERTAPI_TOKEN environment variable. + /// public ConvertApi() { } - /// /// Initializes a new instance of the ConvertApi class. /// - /// The authentication credentials token for ConvertApi. Can be obtained from https://www.convertapi.com/a/authentication + /// The API token. When it is null or empty, the token is read from the CONVERTAPI_TOKEN + /// environment variable. Find your token at https://www.convertapi.com/a/authentication /// The HTTP client for making API requests. public ConvertApi(string apiToken, IConvertApiHttpClient convertApiHttpClient) { @@ -41,15 +57,44 @@ public ConvertApi(string apiToken, IConvertApiHttpClient convertApiHttpClient) /// /// Initializes a new instance of the ConvertApi class. /// - /// The authentication credentials token for ConvertApi. Can be obtained from https://www.convertapi.com/a/authentication + /// The API token. Find your token at https://www.convertapi.com/a/authentication. + /// To read it from the CONVERTAPI_TOKEN environment variable instead, call new ConvertApi(). + /// is null or empty. public ConvertApi(string apiToken) { if (string.IsNullOrEmpty(apiToken)) - throw new ArgumentNullException(nameof(apiToken)); + throw new ArgumentNullException(nameof(apiToken), + "The API token is empty. Pass your token, or set the " + TokenEnvironmentVariable + + " environment variable and call new ConvertApi(). Find your token at " + TokenUrl); ApiToken = apiToken; } + /// + /// The token requests use: if it is set, otherwise the CONVERTAPI_TOKEN + /// environment variable. Throws before anything is sent when neither is set. + /// + internal static string ResolveToken() + { + var token = FindToken(); + if (token == null) + throw new ConvertApiException(HttpStatusCode.Unauthorized, NoTokenMessage, NoTokenMessage); + return token; + } + + // The token if one is set, or null. For requests that also work without a token. + internal static string FindToken() + { + if (!string.IsNullOrEmpty(ApiToken)) + return ApiToken; + var fromEnvironment = Environment.GetEnvironmentVariable(TokenEnvironmentVariable)?.Trim(); + return string.IsNullOrEmpty(fromEnvironment) ? null : fromEnvironment; + } + + private static readonly string NoTokenMessage = + "No ConvertAPI token. Set the " + TokenEnvironmentVariable + " environment variable, or pass the token to " + + "new ConvertApi(\"your-api-token\"). Nothing was uploaded. Find your token at " + TokenUrl; + public static IConvertApiHttpClient GetClient() { return _convertApiHttpClient ?? (_convertApiHttpClient = new DefaultConvertApiHttpClient()); @@ -62,6 +107,7 @@ public async Task ConvertAsync(string fromFormat, string toF public async Task ConvertAsync(string fromFormat, string toFormat, IEnumerable parameters) { + var token = ResolveToken(); var content = new MultipartFormDataContent { { new StringContent("true"), "StoreFile" } @@ -154,7 +200,7 @@ public async Task ConvertAsync(string fromFormat, string toF } - var response = await GetClient().PostAsync(url.Uri, requestTimeOut, content, ApiToken); + var response = await GetClient().PostAsync(url.Uri, requestTimeOut, content, token); var result = await response.Content.ReadAsStringAsync(); if (response.StatusCode != HttpStatusCode.OK) throw new ConvertApiException(response.StatusCode, @@ -288,7 +334,7 @@ public async Task GetConverterInfo(string src, string dst, System.Thr }; try { - var response = await GetClient().GetAsync(url.Uri, ConvertApiConstants.DownloadTimeout, ApiToken); + var response = await GetClient().GetAsync(url.Uri, ConvertApiConstants.DownloadTimeout, FindToken()); if (response.StatusCode != HttpStatusCode.OK) return null; var json = await response.Content.ReadAsStringAsync(); @@ -328,12 +374,13 @@ private static string GetExtensionString(System.Collections.Generic.IDictionary< /// Returns account status like user name, credits left and other information public async Task GetUserAsync() { + var token = ResolveToken(); var url = new UriBuilder(ApiBaseUri) { Path = "user" }; - var response = await GetClient().GetAsync(url.Uri, ConvertApiConstants.DownloadTimeout, ApiToken); + var response = await GetClient().GetAsync(url.Uri, ConvertApiConstants.DownloadTimeout, token); var result = await response.Content.ReadAsStringAsync(); if (response.StatusCode != HttpStatusCode.OK) throw new ConvertApiException(response.StatusCode, $"Retrieve user information failed. {response.ReasonPhrase}", result); diff --git a/ConvertApi/ConvertApiParam.cs b/ConvertApi/ConvertApiParam.cs index b796785..b9a8edb 100644 --- a/ConvertApi/ConvertApiParam.cs +++ b/ConvertApi/ConvertApiParam.cs @@ -179,6 +179,8 @@ private static async Task Upload(Stream fileStream, string fileN HttpResponseMessage responseMessage; using (var content = new StreamContent(fileStream)) { + // Inside the using, so a missing token still disposes the stream, as a failed upload always did. + var token = ConvertApi.ResolveToken(); content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); content.Headers.ContentDisposition = new ContentDispositionHeaderValue("attachment") { @@ -190,7 +192,7 @@ private static async Task Upload(Stream fileStream, string fileN Path = "/upload", }; - responseMessage = await ConvertApi.GetClient().PostAsync(url.Uri, ConvertApiConstants.UploadTimeout, content, ConvertApi.ApiToken); + responseMessage = await ConvertApi.GetClient().PostAsync(url.Uri, ConvertApiConstants.UploadTimeout, content, token); } var result = await responseMessage.Content.ReadAsStringAsync(); @@ -204,13 +206,14 @@ private static async Task Upload(Stream fileStream, string fileN private static async Task Upload(Uri remoteFileUrl) { + var token = ConvertApi.ResolveToken(); var url = new UriBuilder(ConvertApi.ApiBaseUri) { Path = "/upload", Query = $"url={WebUtility.UrlEncode(remoteFileUrl.ToString())}" }; - var responseMessage = await ConvertApi.GetClient().PostAsync(url.Uri, ConvertApiConstants.UploadTimeout, null, ConvertApi.ApiToken); + var responseMessage = await ConvertApi.GetClient().PostAsync(url.Uri, ConvertApiConstants.UploadTimeout, null, token); var result = await responseMessage.Content.ReadAsStringAsync(); if (responseMessage.StatusCode != HttpStatusCode.OK) { diff --git a/README.md b/README.md index a171716..feaabf9 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,8 @@ Keep the token out of your source code. The snippets read it from the `CONVERTAP var convertApi = new ConvertApi(Environment.GetEnvironmentVariable("CONVERTAPI_TOKEN")); ``` +From version 3.3.0, `new ConvertApi()` reads `CONVERTAPI_TOKEN` by itself. A token you pass in code always comes first. If neither is set, the first request throws a `ConvertApiException` with status 401 before anything is uploaded. + ### Set the conversion location (optional) By default, each request goes to the nearest ConvertAPI server. To keep files in one region, for example in the EU, set the API address once, before the first conversion. All addresses are listed in [server locations](https://www.convertapi.com/docs/servers-location). @@ -942,7 +944,7 @@ When the API returns an error, the SDK throws a `ConvertApiException`. Its `Stat - **403 Forbidden:** the account has no conversions left, or it is suspended. - **503 Service Unavailable:** the service is temporarily unavailable. Wait a few seconds and retry, as the [HandleRetry](https://github.com/ConvertAPI/convertapi-library-dotnet/blob/main/Examples/HandleRetry/Program.cs) example does. -For other codes, `Response` holds the error message from the API. If `CONVERTAPI_TOKEN` is not set, the `ConvertApi` constructor throws an `ArgumentNullException` before any request is sent. +For other codes, `Response` holds the error message from the API. If `CONVERTAPI_TOKEN` is not set, `new ConvertApi(Environment.GetEnvironmentVariable("CONVERTAPI_TOKEN"))` throws an `ArgumentNullException` before any request is sent. ```csharp using ConvertApiDotNet;