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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
155 changes: 155 additions & 0 deletions ConvertApi.Tests/TokenTests.cs
Original file line number Diff line number Diff line change
@@ -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<ConvertApiException>(() =>
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<ConvertApiException>(() =>
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<ConvertApiException>(() => 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<ArgumentNullException>(() => _ = 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<string?> Tokens { get; } = new();

protected override Task<HttpResponseMessage> 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"),
});
}
}
}
61 changes: 54 additions & 7 deletions ConvertApi/ConvertApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,35 @@ namespace ConvertApiDotNet
{
public class ConvertApi
{
/// <summary>
/// The name of the environment variable the SDK reads the API token from when no token is set in code.
/// </summary>
public const string TokenEnvironmentVariable = "CONVERTAPI_TOKEN";

/// <summary>
/// The API token set in code. When it is null or empty, the SDK reads the token from the
/// <c>CONVERTAPI_TOKEN</c> environment variable at each request.
/// </summary>
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";

/// <summary>
/// Initializes a new instance of the ConvertApi class that uses the token set in <see cref="ApiToken"/>, or
/// else the token in the <c>CONVERTAPI_TOKEN</c> environment variable.
/// </summary>
public ConvertApi()
{
}


/// <summary>
/// Initializes a new instance of the ConvertApi class.
/// </summary>
/// <param name="apiToken">The authentication credentials token for ConvertApi. Can be obtained from https://www.convertapi.com/a/authentication</param>
/// <param name="apiToken">The API token. When it is null or empty, the token is read from the <c>CONVERTAPI_TOKEN</c>
/// environment variable. Find your token at https://www.convertapi.com/a/authentication</param>
/// <param name="convertApiHttpClient">The HTTP client for making API requests.</param>
public ConvertApi(string apiToken, IConvertApiHttpClient convertApiHttpClient)
{
Expand All @@ -41,15 +57,44 @@ public ConvertApi(string apiToken, IConvertApiHttpClient convertApiHttpClient)
/// <summary>
/// Initializes a new instance of the ConvertApi class.
/// </summary>
/// <param name="apiToken">The authentication credentials token for ConvertApi. Can be obtained from https://www.convertapi.com/a/authentication</param>
/// <param name="apiToken">The API token. Find your token at https://www.convertapi.com/a/authentication.
/// To read it from the <c>CONVERTAPI_TOKEN</c> environment variable instead, call <c>new ConvertApi()</c>.</param>
/// <exception cref="ArgumentNullException"><paramref name="apiToken"/> is null or empty.</exception>
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;
}

/// <summary>
/// The token requests use: <see cref="ApiToken"/> if it is set, otherwise the <c>CONVERTAPI_TOKEN</c>
/// environment variable. Throws before anything is sent when neither is set.
/// </summary>
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());
Expand All @@ -62,6 +107,7 @@ public async Task<ConvertApiResponse> ConvertAsync(string fromFormat, string toF

public async Task<ConvertApiResponse> ConvertAsync(string fromFormat, string toFormat, IEnumerable<ConvertApiBaseParam> parameters)
{
var token = ResolveToken();
var content = new MultipartFormDataContent
{
{ new StringContent("true"), "StoreFile" }
Expand Down Expand Up @@ -154,7 +200,7 @@ public async Task<ConvertApiResponse> 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,
Expand Down Expand Up @@ -288,7 +334,7 @@ public async Task<Converter> 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();
Expand Down Expand Up @@ -328,12 +374,13 @@ private static string GetExtensionString(System.Collections.Generic.IDictionary<
/// <returns>Returns account status like user name, credits left and other information</returns>
public async Task<ConvertApiUser> 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);
Expand Down
7 changes: 5 additions & 2 deletions ConvertApi/ConvertApiParam.cs
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,8 @@ private static async Task<ConvertApiFile> 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")
{
Expand All @@ -190,7 +192,7 @@ private static async Task<ConvertApiFile> 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();
Expand All @@ -204,13 +206,14 @@ private static async Task<ConvertApiFile> Upload(Stream fileStream, string fileN

private static async Task<ConvertApiFile> 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)
{
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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;
Expand Down
Loading