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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,21 @@ The format is based on Keep a Changelog and this project adheres to Semantic Ver
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`.
- The message of a `ConvertApiException` with status 401 or 403 says what to do and links to the page that
helps: "The API token is missing or invalid. Find or get your token at ..." and "No conversions left or the
account is suspended. Plans: ...". The exception type, `StatusCode` and `Response` are unchanged.
- XML documentation (`ConvertApi.xml`) ships in the package, so IntelliSense describes `ConvertAsync`,
`ConvertApiFileParam`, the save and delete helpers and `ConvertApiException`, including what 401 and 403 mean.

### Fixed
- `SaveFileAsync` and `SaveFilesAsync` replace an existing file instead of writing over its start. They
open it with `FileInfo.OpenWrite`, which does not truncate, and did not cut it to the new length, so a
result saved over a longer file kept that file's trailing bytes and came out corrupt: a 146,734-byte PDF
saved over a 400,000-byte file stayed 400,000 bytes. Every version since 2.0.0 (2018) has this bug.
Hidden and system files on Windows are still written, as before.
- `SaveFileAsync`, `SaveFilesAsync` and `FileStreamAsync` throw a `ConvertApiException` (with the status and the
body) when the download answers anything but 200 OK. Earlier versions returned the error page as the file, so
`SaveFileAsync` wrote it into the output file, replacing a good one. Now no file is written.

### Changed
- `SaveFilesAsync(outputDirectory)` creates the folder if it does not exist, instead of throwing
Expand All @@ -40,6 +48,11 @@ The format is based on Keep a Changelog and this project adheres to Semantic Ver
no test runs.
- The package readme on nuget.org is the repository's `README.md`; the separate `ConvertApi/docs/README.md`
had drifted and is removed. The publish workflow tags the packed copy's links `utm_source=nuget`.
- Package metadata: the nuget.org project link goes to the C# page on convertapi.com, the description is plain
ASCII and names the formats, the tags add common searches (word-to-pdf, pdf-to-word, excel-to-pdf and others,
and a bare `to`, without which nuget.org never finds the package for searches like "docx to pdf") and drop the
misspelled `prdf-extract` and `pdf2extract`. The icon is embedded in the package instead of the deprecated
`PackageIconUrl`.
- README: a quick start, C# task sections, and snippets that run as copied on Windows, macOS and Linux.
They save to a relative `output` folder they create, use `ConvertApiFile` (not `ProcessedFile`),
`FitToSize` (not the ignored `ScaleImage` and `ScaleProportions`) and the current converter names.
Expand Down
200 changes: 200 additions & 0 deletions ConvertApi.Tests/ErrorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
using System.Net;
using System.Text;
using ConvertApiDotNet.Exceptions;
using ConvertApiDotNet.Interface;
using ConvertApiDotNet.Model;

namespace ConvertApiDotNet.Tests;

// What an error answer turns into: a ConvertApiException with the status and the body, and for 401 and 403 a
// message that says what to do. Also: a download that is not 200 OK throws instead of returning the error page.
// The tests change the static token and client, so they restore both and must not run in parallel with others.
[NonParallelizable]
public class ErrorTests
{
private const string Body = """{"Code":4000,"Message":"from the server"}""";
private static readonly Uri ResultUrl = new("https://v2.convertapi.com/d/test/document.pdf");

private string? _savedToken;
private IConvertApiHttpClient? _savedClient;
private string _dir = "";
private StatusServer _server = null!;

[SetUp]
public void SetUp()
{
_savedToken = ConvertApi.ApiToken;
_savedClient = ConvertApi.GetClient();
_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 StatusServer();
_ = new ConvertApi("test-token", new FakeClient(_server));
}

[TearDown]
public void TearDown()
{
_ = new ConvertApi(_savedToken!, _savedClient!);
Directory.Delete(_dir, recursive: true);
_server.Dispose();
}

[Test]
public async Task A401FromConvertSaysTheTokenIsMissingOrInvalid()
{
_server.Status = HttpStatusCode.Unauthorized;

var error = await ThrowsFrom(() => new ConvertApi().ConvertAsync("docx", "pdf", new ConvertApiParam("File", "https://example.com/a.docx")));

using (Assert.EnterMultipleScope())
{
Assert.That(error.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
Assert.That(error.Response, Is.EqualTo(Body));
Assert.That(error.Message, Does.StartWith("Conversion from docx to pdf error. Unauthorized. The API token"));
Assert.That(error.Message, Does.Contain("The API token is missing or invalid. Find or get your token at https://www.convertapi.com/a/signup?ReturnUrl=%2Fa%2Fauthentication&utm_source=dotnet-sdk&utm_medium=referral&utm_campaign=dotnet-sdk&utm_content=error-401"));
}
}

[Test]
public async Task A403FromConvertSaysNoConversionsAreLeft()
{
_server.Status = HttpStatusCode.Forbidden;

var error = await ThrowsFrom(() => new ConvertApi().ConvertAsync("docx", "pdf", new ConvertApiParam("File", "https://example.com/a.docx")));

using (Assert.EnterMultipleScope())
{
Assert.That(error.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden));
Assert.That(error.Response, Is.EqualTo(Body));
Assert.That(error.Message, Does.Contain("No conversions left or the account is suspended. Plans: https://www.convertapi.com/pricing?utm_source=dotnet-sdk&utm_medium=referral&utm_campaign=dotnet-sdk&utm_content=error-403"));
}
}

[Test]
public async Task A401FromTheUploadGetsTheSameHelp()
{
_server.Status = HttpStatusCode.Unauthorized;

var error = await ThrowsFrom(() => new ConvertApi().ConvertAsync("docx", "pdf", new ConvertApiFileParam(Path.Combine(_dir, "document.docx"))));

using (Assert.EnterMultipleScope())
{
Assert.That(error.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
Assert.That(error.Message, Does.StartWith("Unable to upload file."));
Assert.That(error.Message, Does.Contain("utm_content=error-401"));
}
}

[Test]
public async Task A403FromGetUserGetsTheSameHelp()
{
_server.Status = HttpStatusCode.Forbidden;

var error = await ThrowsFrom(() => new ConvertApi().GetUserAsync());

Assert.That(error.Message, Does.Contain("utm_content=error-403"));
}

[Test]
public async Task OtherErrorsKeepTheirMessage()
{
_server.Status = HttpStatusCode.InternalServerError;

var error = await ThrowsFrom(() => new ConvertApi().ConvertAsync("docx", "pdf", new ConvertApiParam("File", "https://example.com/a.docx")));

using (Assert.EnterMultipleScope())
{
Assert.That(error.StatusCode, Is.EqualTo(HttpStatusCode.InternalServerError));
Assert.That(error.Message, Is.EqualTo("Conversion from docx to pdf error. Internal Server Error"));
Assert.That(error.Response, Is.EqualTo(Body));
}
}

[Test]
public async Task AFailedDownloadThrowsAndWritesNoFile()
{
_server.Status = HttpStatusCode.NotFound;
var target = Path.Combine(_dir, "result.pdf");

var error = await ThrowsFrom(() => Result().SaveFileAsync(target));

using (Assert.EnterMultipleScope())
{
Assert.That(error.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
Assert.That(error.Message, Does.StartWith("Unable to download file."));
Assert.That(error.Response, Is.EqualTo(Body));
Assert.That(File.Exists(target), Is.False, "the error page must not be saved as the result");
}
}

[Test]
public async Task AFailedDownloadLeavesAnExistingFileAlone()
{
_server.Status = HttpStatusCode.NotFound;
var target = Path.Combine(_dir, "result.pdf");
File.WriteAllText(target, "the old result");

await ThrowsFrom(() => Result().SaveFileAsync(target));

Assert.That(File.ReadAllText(target), Is.EqualTo("the old result"));
}

[Test]
public async Task AFailedDownloadAsAStreamThrows()
{
_server.Status = HttpStatusCode.Forbidden;

var error = await ThrowsFrom(() => Result().FileStreamAsync());

using (Assert.EnterMultipleScope())
{
Assert.That(error.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden));
Assert.That(error.Message, Does.Contain("utm_content=error-403"));
}
}

[Test]
public async Task ASuccessfulDownloadStillWorks()
{
_server.Status = HttpStatusCode.OK;
var target = Path.Combine(_dir, "result.pdf");

await Result().SaveFileAsync(target);

Assert.That(File.ReadAllText(target), Is.EqualTo(Body));
}

private static ConvertApiFile Result() => new() { FileName = "document.pdf", FileSize = 3, Url = ResultUrl };

private static async Task<ConvertApiException> ThrowsFrom(Func<Task> action)
{
try
{
await action();
}
catch (ConvertApiException error)
{
return error;
}
Assert.Fail("Expected a ConvertApiException.");
return null!;
}

private sealed class FakeClient(HttpMessageHandler handler) : IConvertApiHttpClient
{
public HttpClient Client { get; } = new(handler);
}

// Answers every request with Status and the same JSON body.
private sealed class StatusServer : HttpMessageHandler
{
public HttpStatusCode Status { get; set; } = HttpStatusCode.OK;

protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
Task.FromResult(new HttpResponseMessage(Status)
{
Content = new StringContent(Body, Encoding.UTF8, "application/json"),
});
}
}
18 changes: 15 additions & 3 deletions ConvertApi/ConvertApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,22 @@ public static IConvertApiHttpClient GetClient()
return _convertApiHttpClient ?? (_convertApiHttpClient = new DefaultConvertApiHttpClient());
}

/// <summary>
/// Convert files from one format to another, for example <c>ConvertAsync("docx", "pdf", new ConvertApiFileParam("file.docx"))</c>.
/// Waits for the uploads of every <see cref="ConvertApiFileParam"/>, then converts.
/// </summary>
/// <param name="fromFormat">The source format, such as "docx", "html" or "pdf".</param>
/// <param name="toFormat">The target format, such as "pdf", "jpg" or "docx".</param>
/// <param name="parameters">The files and conversion options.</param>
/// <returns>The result files, still on the server: save them with <c>SaveFilesAsync</c> or <c>SaveFileAsync</c>.</returns>
/// <exception cref="ConvertApiException">The service answered with an error. 401: the token is missing or
/// invalid. 403: no conversions left or the account is suspended.</exception>
public async Task<ConvertApiResponse> ConvertAsync(string fromFormat, string toFormat, params ConvertApiBaseParam[] parameters)
{
return await ConvertAsync(fromFormat, toFormat, (IEnumerable<ConvertApiBaseParam>)parameters);
}

/// <inheritdoc cref="ConvertAsync(string, string, ConvertApiBaseParam[])"/>
public async Task<ConvertApiResponse> ConvertAsync(string fromFormat, string toFormat, IEnumerable<ConvertApiBaseParam> parameters)
{
var token = ResolveToken();
Expand Down Expand Up @@ -203,8 +214,8 @@ public async Task<ConvertApiResponse> ConvertAsync(string fromFormat, string toF
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,
$"Conversion from {fromFormat} to {toFormat} error. {response.ReasonPhrase}", result);
throw new ConvertApiException(response.StatusCode, ConvertApiException.MessageFor(response.StatusCode,
$"Conversion from {fromFormat} to {toFormat} error. {response.ReasonPhrase}"), result);
var apiResponse = JsonConvert.DeserializeObject<ConvertApiResponse>(result);
if (apiResponse != null)
{
Expand Down Expand Up @@ -383,7 +394,8 @@ public async Task<ConvertApiUser> GetUserAsync()
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);
throw new ConvertApiException(response.StatusCode,
ConvertApiException.MessageFor(response.StatusCode, $"Retrieve user information failed. {response.ReasonPhrase}"), result);
return JsonConvert.DeserializeObject<ConvertApiUser>(result);
}
}
Expand Down
17 changes: 13 additions & 4 deletions ConvertApi/ConvertApi.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,28 @@
<Version>0.0.0-local</Version>
<Company>ConvertApi</Company>
<Authors>ConvertApi</Authors>
<PackageProjectUrl>https://www.convertapi.com</PackageProjectUrl>
<PackageProjectUrl>https://www.convertapi.com/integrations/libraries/csharp?utm_source=nuget&amp;utm_medium=referral&amp;utm_campaign=dotnet-sdk&amp;utm_content=project-url</PackageProjectUrl>
<RepositoryUrl>https://github.com/ConvertAPI/convertapi-library-dotnet</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageTags>file-converter document-converter office-to-pdf office2pdf docx-to-pdf docx2pdf xlsx-to-pdf xlsx2pdf pptx-to-pdf ppt2pdf pdf-merge pdf-split pdf-compress pdf-watermark prdf-extract pdf2extract pdf-protect pdfa ocr redact html-to-pdf web-to-pdf image-to-pdf jpg-to-pdf png-to-pdf email-to-pdf eml-to-pdf msg-to-pdf convertapi pdf docx xlsx pptx office converter csharp dotnet sdk </PackageTags>
<!-- The bare "to" tag is on purpose. nuget.org search needs every word of a query to match, and it matches "to"
only against package IDs and tags, so without it ConvertApi never shows for searches like "docx to pdf". -->
<PackageTags>file-converter document-converter office-to-pdf office2pdf docx-to-pdf docx2pdf word-to-pdf xlsx-to-pdf xlsx2pdf excel-to-pdf pptx-to-pdf ppt2pdf pdf-to-word pdf-to-docx pdf-to-image pdf-to-text html-to-docx pdf-merge pdf-split pdf-compress pdf-watermark pdf-protect pdfa ocr redact html-to-pdf web-to-pdf image-to-pdf jpg-to-pdf png-to-pdf email-to-pdf eml-to-pdf msg-to-pdf convertapi pdf docx xlsx pptx word excel powerpoint html image office converter csharp dotnet sdk to</PackageTags>
<Copyright>2012-2026 ConvertApi</Copyright>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageIconUrl>https://www.convertapi.com/favicon/android-chrome-192x192.png</PackageIconUrl>
<PackageIcon>icon.png</PackageIcon>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageId>ConvertApi</PackageId>
<Product>ConvertApi</Product>
<AssemblyName>ConvertApi</AssemblyName>
<RootNamespace>ConvertApiDotNet</RootNamespace>
<PackageReleaseNotes>https://github.com/ConvertAPI/convertapi-library-dotnet/releases</PackageReleaseNotes>
<TargetFramework>netstandard2.0</TargetFramework>
<Description>ConvertAPI for .NET — one call to turn Office files into PDF or back again (DOCX ⇄ PDF, XLSX ⇄ PDF, PPTX ⇄ PDF). Convert HTML, images, ebooks, email, zip; merge, split, watermark, compress, OCR, redact, protect or repair PDF. Async and cross-platform: targets .NET Standard 2.0, so it runs on .NET Framework 4.6.2+ and every modern .NET.</Description>
<!-- Plain ASCII: nuget.org search and some feeds show other characters badly. -->
<Description>ConvertAPI C# and .NET SDK for the ConvertAPI REST API: convert Word, Excel and PowerPoint to PDF and back, HTML to PDF or DOCX, images to PDF and PDF to images or text. Merge, split, compress, watermark, OCR, redact and protect PDF. Async, targets .NET Standard 2.0, so it runs on .NET Framework 4.6.2+ and every modern .NET. Free trial at convertapi.com.</Description>
<!-- ConvertApi.xml ships in the package, so IntelliSense shows the docs. Only the main entry points have them, so
the warning for every undocumented public member stays off. -->
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<!-- A library passes its dependencies on to every consumer, so a known high or critical advisory in any of them,
Expand All @@ -39,6 +46,8 @@
<!-- nuget.org shows the repository's own README.md, so the package page and GitHub cannot drift apart.
The publish workflow rewrites utm_source=github to utm_source=nuget in the packed copy. -->
<None Include="..\README.md" Pack="true" PackagePath="\" />
<!-- The ConvertAPI logo, 128x128, from https://www.convertapi.com/favicon/android-chrome-512x512.png. -->
<None Include="icon.png" Pack="true" PackagePath="\" />
</ItemGroup>

</Project>
Loading
Loading