From da214d71e9ed9a73523b44dd0cd9600e6c02d691 Mon Sep 17 00:00:00 2001 From: Tomas Rutkauskas Date: Thu, 12 Nov 2026 10:08:44 +0200 Subject: [PATCH 1/2] 3.3.0 part B: 401/403 help, failed downloads throw, NuGet metadata and icon, XML docs, release checklist - ConvertApiException messages for 401 and 403 say what to do and link to the token page and the plans, tagged utm_source=dotnet-sdk ... utm_content=error-401/error-403. Type, StatusCode and Response unchanged. - Downloads (SaveFileAsync, SaveFilesAsync, FileStreamAsync) throw ConvertApiException unless the answer is 200 OK, instead of saving the error page as the result. - csproj: tags (common searches, the bare "to" with a comment, typos dropped), plain-ASCII description, PackageProjectUrl to the C# page, embedded 128x128 icon instead of PackageIconUrl (NU5048), and GenerateDocumentationFile with summaries on the main entry points. - RELEASING.md: owner TODO, pre-release checklist and a release notes template. CHANGELOG and README. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 13 ++ ConvertApi.Tests/ErrorTests.cs | 200 +++++++++++++++++++ ConvertApi/ConvertApi.cs | 18 +- ConvertApi/ConvertApi.csproj | 17 +- ConvertApi/ConvertApiExtension.cs | 22 ++ ConvertApi/ConvertApiParam.cs | 35 +++- ConvertApi/Exceptions/ConvertApiException.cs | 47 ++++- ConvertApi/icon.png | Bin 0 -> 7073 bytes README.md | 2 + RELEASING.md | 43 ++++ 10 files changed, 387 insertions(+), 10 deletions(-) create mode 100644 ConvertApi.Tests/ErrorTests.cs create mode 100644 ConvertApi/icon.png diff --git a/CHANGELOG.md b/CHANGELOG.md index fe1a5e7..af4d201 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,11 @@ 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 @@ -22,6 +27,9 @@ The format is based on Keep a Changelog and this project adheres to Semantic Ver 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 @@ -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. diff --git a/ConvertApi.Tests/ErrorTests.cs b/ConvertApi.Tests/ErrorTests.cs new file mode 100644 index 0000000..cb7a081 --- /dev/null +++ b/ConvertApi.Tests/ErrorTests.cs @@ -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 ThrowsFrom(Func 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 SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(new HttpResponseMessage(Status) + { + Content = new StringContent(Body, Encoding.UTF8, "application/json"), + }); + } +} diff --git a/ConvertApi/ConvertApi.cs b/ConvertApi/ConvertApi.cs index bc05c5c..dbd9d94 100644 --- a/ConvertApi/ConvertApi.cs +++ b/ConvertApi/ConvertApi.cs @@ -100,11 +100,22 @@ public static IConvertApiHttpClient GetClient() return _convertApiHttpClient ?? (_convertApiHttpClient = new DefaultConvertApiHttpClient()); } + /// + /// Convert files from one format to another, for example ConvertAsync("docx", "pdf", new ConvertApiFileParam("file.docx")). + /// Waits for the uploads of every , then converts. + /// + /// The source format, such as "docx", "html" or "pdf". + /// The target format, such as "pdf", "jpg" or "docx". + /// The files and conversion options. + /// The result files, still on the server: save them with SaveFilesAsync or SaveFileAsync. + /// The service answered with an error. 401: the token is missing or + /// invalid. 403: no conversions left or the account is suspended. public async Task ConvertAsync(string fromFormat, string toFormat, params ConvertApiBaseParam[] parameters) { return await ConvertAsync(fromFormat, toFormat, (IEnumerable)parameters); } + /// public async Task ConvertAsync(string fromFormat, string toFormat, IEnumerable parameters) { var token = ResolveToken(); @@ -203,8 +214,8 @@ public async Task 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(result); if (apiResponse != null) { @@ -383,7 +394,8 @@ public async Task 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(result); } } diff --git a/ConvertApi/ConvertApi.csproj b/ConvertApi/ConvertApi.csproj index 93b421d..1a0a21c 100644 --- a/ConvertApi/ConvertApi.csproj +++ b/ConvertApi/ConvertApi.csproj @@ -6,13 +6,15 @@ 0.0.0-local ConvertApi ConvertApi - https://www.convertapi.com + https://www.convertapi.com/integrations/libraries/csharp?utm_source=nuget&utm_medium=referral&utm_campaign=dotnet-sdk&utm_content=project-url https://github.com/ConvertAPI/convertapi-library-dotnet git - 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 + + 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 2012-2026 ConvertApi MIT - https://www.convertapi.com/favicon/android-chrome-192x192.png + icon.png README.md ConvertApi ConvertApi @@ -20,7 +22,12 @@ ConvertApiDotNet https://github.com/ConvertAPI/convertapi-library-dotnet/releases netstandard2.0 - 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. + + 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. + + true + $(NoWarn);CS1591 true snupkg + + diff --git a/ConvertApi/ConvertApiExtension.cs b/ConvertApi/ConvertApiExtension.cs index 0a64b93..a06e6d5 100644 --- a/ConvertApi/ConvertApiExtension.cs +++ b/ConvertApi/ConvertApiExtension.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using System.Linq; using ConvertApiDotNet.Constants; +using ConvertApiDotNet.Exceptions; using ConvertApiDotNet.Model; namespace ConvertApiDotNet @@ -25,6 +26,17 @@ public static int FileCount(this ConvertApiResponse response) private static async Task AsStreamAsync(Uri url) { var httpResponseMessage = await ConvertApi.GetClient().GetAsync(url, ConvertApiConstants.DownloadTimeout); + // Anything but 200 is an error page, not the file: throw instead of handing it over (or saving it) as the + // result. Earlier versions returned the error body, so SaveFileAsync wrote it into the output file. + if (httpResponseMessage.StatusCode != HttpStatusCode.OK) + { + using (httpResponseMessage) + { + var body = await httpResponseMessage.Content.ReadAsStringAsync(); + throw new ConvertApiException(httpResponseMessage.StatusCode, ConvertApiException.MessageFor( + httpResponseMessage.StatusCode, $"Unable to download file. {httpResponseMessage.ReasonPhrase}"), body); + } + } return await httpResponseMessage.Content.ReadAsStreamAsync(); } @@ -52,6 +64,7 @@ private static async Task SaveFileAsync(Uri url, string fileName) /// /// Save the first result file as , replacing the file if it exists. /// + /// The download answered with anything but 200 OK. public static async Task SaveFileAsync(this ConvertApiResponse response, string fileName) { return await response.Files[0].SaveFileAsync(fileName); @@ -61,11 +74,18 @@ public static async Task SaveFileAsync(this ConvertApiResponse respons /// Save every result file into under its own name, replacing files /// that exist. The folder is created if it does not exist. /// + /// is blank, is an existing file, or + /// looks like a result file name. To save one result as a file, call SaveFileAsync. + /// A download answered with anything but 200 OK. public static async Task> SaveFilesAsync(this ConvertApiResponse response, string outputDirectory) { return await response.Files.SaveFilesAsync(outputDirectory); } + /// + /// Download the file as a stream, for example to send it on without saving it. Dispose the stream when done. + /// + /// The download answered with anything but 200 OK. public static async Task FileStreamAsync(this ConvertApiFile processedFile) { return await AsStreamAsync(processedFile.Url); @@ -74,6 +94,7 @@ public static async Task FileStreamAsync(this ConvertApiFile processedFi /// /// Save the file as , replacing the file if it exists. /// + /// The download answered with anything but 200 OK; no file is written. public static async Task SaveFileAsync(this ConvertApiFile processedFile, string fileName) { return await SaveFileAsync(processedFile.Url, fileName); @@ -86,6 +107,7 @@ public static async Task SaveFileAsync(this ConvertApiFile processedFi /// is blank, is an existing file, or /// looks like a result file name (for example "result.pdf" for PDF results). To save one result as a file, /// call SaveFileAsync. To use a new folder whose name has an extension, end the path with a slash. + /// A download answered with anything but 200 OK. public static async Task> SaveFilesAsync(this IEnumerable processedFile, string outputDirectory) { var files = processedFile.ToList(); diff --git a/ConvertApi/ConvertApiParam.cs b/ConvertApi/ConvertApiParam.cs index b9a8edb..4a5aaee 100644 --- a/ConvertApi/ConvertApiParam.cs +++ b/ConvertApi/ConvertApiParam.cs @@ -70,6 +70,11 @@ public ConvertApiParam(string name, decimal value) : this(name, value.ToString(C } } + /// + /// A file to convert: a local path, a , a stream, a remote URL, an uploaded FileId, or + /// the result of an earlier conversion. Local files, streams and URLs start uploading as soon as the parameter + /// is created; ConvertAsync waits for them. + /// public class ConvertApiFileParam : ConvertApiBaseParam { private Task Tasks { get; set; } @@ -82,6 +87,9 @@ public ConvertApiFileParam(Uri url) : this("file", url) { } + /// + /// Convert a remote file, passed to the service under the parameter name . + /// public ConvertApiFileParam(string name, Uri url) : base(name) { Tasks = Upload(url); @@ -95,6 +103,9 @@ public ConvertApiFileParam(string path) : this("file", path) { } + /// + /// Convert a local file or an uploaded FileId, passed under the parameter name . + /// public ConvertApiFileParam(string name, string path) : base(name) { if (string.IsNullOrWhiteSpace(path)) @@ -130,6 +141,9 @@ public ConvertApiFileParam(FileInfo file) : this("File", file) { } + /// + /// Convert a local file, passed under the parameter name . + /// public ConvertApiFileParam(string name, FileInfo file) : base(name) { Tasks = Upload(file); @@ -144,23 +158,38 @@ public ConvertApiFileParam(Stream fileStream, string fileName) : this("File", fi { } + /// + /// Convert a stream, passed under the parameter name . The stream is disposed after the upload. + /// public ConvertApiFileParam(string name, Stream fileStream, string fileName) : base(name) { Tasks = Upload(fileStream, fileName); } + /// + /// Convert a result file of an earlier conversion, without downloading it. + /// public ConvertApiFileParam(ConvertApiFile processedFile) : this("File", processedFile) { } + /// + /// Convert a result file of an earlier conversion, passed under the parameter name . + /// public ConvertApiFileParam(string name, ConvertApiFile processedFile) : base(name, processedFile.Url) { } + /// + /// Convert every result file of an earlier conversion, without downloading them. + /// public ConvertApiFileParam(ConvertApiResponse response) : this("File", response) { } + /// + /// Convert every result file of an earlier conversion, passed under the parameter name . + /// public ConvertApiFileParam(string name, ConvertApiResponse response) : base(name) { Value = response.Files.Select(s => s.Url.ToString()).ToArray(); @@ -198,7 +227,8 @@ private static async Task Upload(Stream fileStream, string fileN var result = await responseMessage.Content.ReadAsStringAsync(); if (responseMessage.StatusCode != HttpStatusCode.OK) { - throw new ConvertApiException(responseMessage.StatusCode, $"Unable to upload file. {responseMessage.ReasonPhrase}", result); + throw new ConvertApiException(responseMessage.StatusCode, + ConvertApiException.MessageFor(responseMessage.StatusCode, $"Unable to upload file. {responseMessage.ReasonPhrase}"), result); } return JsonConvert.DeserializeObject(result); @@ -217,7 +247,8 @@ private static async Task Upload(Uri remoteFileUrl) var result = await responseMessage.Content.ReadAsStringAsync(); if (responseMessage.StatusCode != HttpStatusCode.OK) { - throw new ConvertApiException(responseMessage.StatusCode, $"Unable to upload file. {responseMessage.ReasonPhrase}", result); + throw new ConvertApiException(responseMessage.StatusCode, + ConvertApiException.MessageFor(responseMessage.StatusCode, $"Unable to upload file. {responseMessage.ReasonPhrase}"), result); } return JsonConvert.DeserializeObject(result); diff --git a/ConvertApi/Exceptions/ConvertApiException.cs b/ConvertApi/Exceptions/ConvertApiException.cs index eb81e39..e39dbc5 100644 --- a/ConvertApi/Exceptions/ConvertApiException.cs +++ b/ConvertApi/Exceptions/ConvertApiException.cs @@ -1,18 +1,63 @@ -using System; +using System; using System.Net; namespace ConvertApiDotNet.Exceptions { + /// + /// The ConvertAPI service, or a download from it, answered with an error. is the HTTP + /// status and the body the service sent. + /// + /// + /// 401 Unauthorized: the API token is missing or invalid. The SDK also throws 401, before sending + /// anything, when no token is set in code or in the CONVERTAPI_TOKEN environment variable. + /// 403 Forbidden: the account has no conversions left, or it is suspended. + /// For both, the message says what to do and links to the page that helps. + /// public class ConvertApiException : Exception { + private const string Utm = "utm_source=dotnet-sdk&utm_medium=referral&utm_campaign=dotnet-sdk&utm_content="; + internal const string TokenHelpUrl = "https://www.convertapi.com/a/signup?ReturnUrl=%2Fa%2Fauthentication&" + Utm + "error-401"; + internal const string PlansUrl = "https://www.convertapi.com/pricing?" + Utm + "error-403"; + + /// + /// Create the exception for an error answer. + /// + /// The HTTP status of the answer. + /// What failed. + /// The body of the answer. public ConvertApiException(HttpStatusCode statusCode, string message, string response) : base(message) { StatusCode = statusCode; Response = response; } + /// + /// The HTTP status of the answer, for example 401 for a missing or invalid token and 403 when no + /// conversions are left. + /// public HttpStatusCode StatusCode { get; } + + /// + /// The body of the answer, usually JSON with the service's own error message. + /// public string Response { get; } + /// + /// The message for an error answer: , plus what to do for 401 and 403. + /// + internal static string MessageFor(HttpStatusCode statusCode, string message) + { + if (statusCode != HttpStatusCode.Unauthorized && statusCode != HttpStatusCode.Forbidden) + return message; + // "... error. Unauthorized" ends without a full stop; add one before the next sentence. + message = message.TrimEnd(); + if (!message.EndsWith(".")) + message += "."; + if (statusCode == HttpStatusCode.Unauthorized) + return message + " The API token is missing or invalid. Find or get your token at " + TokenHelpUrl; + if (statusCode == HttpStatusCode.Forbidden) + return message + " No conversions left or the account is suspended. Plans: " + PlansUrl; + return message; + } } } diff --git a/ConvertApi/icon.png b/ConvertApi/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..4fedd9df0bcdeea76a34cb3351b9bf652228dc2a GIT binary patch literal 7073 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 F{{UEDR8;@~ literal 0 HcmV?d00001 diff --git a/README.md b/README.md index feaabf9..d2ad4a3 100644 --- a/README.md +++ b/README.md @@ -946,6 +946,8 @@ When the API returns an error, the SDK throws a `ConvertApiException`. Its `Stat 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. +From version 3.3.0, the message of a 401 or 403 also says what to do and links to the page that helps, and `SaveFileAsync`, `SaveFilesAsync` and `FileStreamAsync` throw a `ConvertApiException` when a result cannot be downloaded, instead of saving the error page as the file. + ```csharp using ConvertApiDotNet; using ConvertApiDotNet.Exceptions; diff --git a/RELEASING.md b/RELEASING.md index ac26f88..02b3b1f 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -26,6 +26,49 @@ That runs `.github/workflows/publish.yml` in the `nuget` environment, which: gh release create v3.2.0-preview.1 --repo ConvertAPI/convertapi-library-dotnet --target my-branch --prerelease --title v3.2.0-preview.1 --notes "Preview of ..." ``` +## Release owner + +**TODO: name the one person who owns each release** (runs the checklist, publishes, answers for the +notes). Until that is decided, note that `publish.yml` only accepts a Release published by `tomasr78`. + +## Before each release + +- [ ] `CHANGELOG.md`: move the `[Unreleased]` entries under `## [x.y.z] - YYYY-MM-DD` and leave an empty + `[Unreleased]` above it. +- [ ] Preview the README as nuget.org will show it: download the `package` artifact of the latest `build` + run on `main`, open `README.md` inside the `.nupkg` (it is a zip), and check that it renders and that + its links work. Check the icon, description and tags in the `.nuspec` next to it. +- [ ] Bump every pinned SDK version to the new one: install lines or `Version="..."` in the README, the + examples and any snippet that names a version. +- [ ] Check that the C# quick start on https://www.convertapi.com/integrations/libraries/csharp still + matches the README (install command, token, first conversion). If it does not, ask for the page to + be updated before announcing the release. +- [ ] Write the release notes with the template below, then publish. + +## Release notes template + +```markdown +## What's new + +- +- + +## Fixed + +- + +## Upgrading + +<"Drop-in: no code changes." or what to change, with a short snippet.> + +Full changelog: https://github.com/ConvertAPI/convertapi-library-dotnet/blob/main/CHANGELOG.md +Install: `dotnet add package ConvertApi --version x.y.z` +``` + +Pass it with `--notes-file notes.md` instead of `--generate-notes`. + +## Mistakes + nuget.org versions are permanent. A bad version can only be unlisted (and, if needed, deprecated) on nuget.org, then fixed by releasing the next version. Never delete and re-create a release tag. From a4c57d051b898bd9ecc954adac81373b85d1cb1b Mon Sep 17 00:00:00 2001 From: Tomas Rutkauskas Date: Thu, 12 Nov 2026 10:55:34 +0200 Subject: [PATCH 2/2] RELEASING.md: name the build artifact as build.yml uploads it Co-Authored-By: Claude Opus 5.5 --- RELEASING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASING.md b/RELEASING.md index 02b3b1f..369cb3d 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -35,7 +35,7 @@ notes). Until that is decided, note that `publish.yml` only accepts a Release pu - [ ] `CHANGELOG.md`: move the `[Unreleased]` entries under `## [x.y.z] - YYYY-MM-DD` and leave an empty `[Unreleased]` above it. -- [ ] Preview the README as nuget.org will show it: download the `package` artifact of the latest `build` +- [ ] Preview the README as nuget.org will show it: download the `ConvertApi-0.0.0-ci.{run}.g{sha}` artifact of the latest `build` run on `main`, open `README.md` inside the `.nupkg` (it is a zip), and check that it renders and that its links work. Check the icon, description and tags in the `.nuspec` next to it. - [ ] Bump every pinned SDK version to the new one: install lines or `Version="..."` in the README, the