diff --git a/.github/workflows/run_ci.yml b/.github/workflows/run_ci.yml
index 7dbd0ae..1d1c339 100644
--- a/.github/workflows/run_ci.yml
+++ b/.github/workflows/run_ci.yml
@@ -49,28 +49,31 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
- dotnet-version: [ '3.1.x', '5.0.x' ]
configuration: [ Debug, Release ]
steps:
- name: Checkout
uses: actions/checkout@v4
- - name: Setup .NET ${{ matrix.dotnet-version }}
+ - name: Setup .NET 8
uses: actions/setup-dotnet@v4
with:
- dotnet-version: ${{ matrix.dotnet-version }}
+ dotnet-version: '8.0.x'
- name: Restore dependencies
- run: dotnet restore
+ run: dotnet restore DeepL.net.sln
- name: Build ${{ matrix.configuration }}
- run: dotnet build --no-restore --configuration ${{ matrix.configuration }}
+ run: dotnet build DeepL.net.sln --no-restore --configuration ${{ matrix.configuration }}
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
- name: build-artifacts ${{ matrix.dotnet-version }}-${{ matrix.configuration }}
+ name: build-artifacts-${{ matrix.configuration }}
path: |
DeepL/bin/
DeepL/obj/
DeepLTests/bin/
DeepLTests/obj/
+ DeepL.Extensions.DependencyInjection/bin/
+ DeepL.Extensions.DependencyInjection/obj/
+ DeepL.Extensions.DependencyInjection.Tests/bin/
+ DeepL.Extensions.DependencyInjection.Tests/obj/
# Test and `nuget publish` stage are disabled for now. Code needs to be tested
diff --git a/DeepL.Extensions.DependencyInjection.Tests/DeepL.Extensions.DependencyInjection.Tests.csproj b/DeepL.Extensions.DependencyInjection.Tests/DeepL.Extensions.DependencyInjection.Tests.csproj
new file mode 100644
index 0000000..6a95721
--- /dev/null
+++ b/DeepL.Extensions.DependencyInjection.Tests/DeepL.Extensions.DependencyInjection.Tests.csproj
@@ -0,0 +1,28 @@
+
+
+
+ net8.0
+ 12
+ true
+ nullable
+ enable
+ false
+ DeepL.Extensions.DependencyInjection.Tests
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
diff --git a/DeepL.Extensions.DependencyInjection.Tests/DeepLServiceCollectionExtensionsTest.cs b/DeepL.Extensions.DependencyInjection.Tests/DeepLServiceCollectionExtensionsTest.cs
new file mode 100644
index 0000000..702573b
--- /dev/null
+++ b/DeepL.Extensions.DependencyInjection.Tests/DeepLServiceCollectionExtensionsTest.cs
@@ -0,0 +1,229 @@
+// Copyright 2026 DeepL SE (https://www.deepl.com)
+// Use of this source code is governed by an MIT
+// license that can be found in the LICENSE file.
+
+using System;
+using System.Collections.Generic;
+using System.Net.Http;
+using DeepL.Extensions.DependencyInjection;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Xunit;
+
+namespace DeepL.Extensions.DependencyInjection.Tests {
+ ///
+ /// Tests for .
+ /// These are pure DI-container tests — no DeepL API is called, the configured auth key
+ /// is only used to construct the (construction is lazy-safe).
+ ///
+ public sealed class DeepLServiceCollectionExtensionsTest {
+ private const string FakeKey = "00000000-0000-0000-0000-000000000000:fx";
+
+ private static ServiceProvider BuildProvider(Action configureServices) {
+ var services = new ServiceCollection();
+ configureServices(services);
+ return services.BuildServiceProvider();
+ }
+
+ // ---------- Configure overload ----------
+
+ [Fact]
+ public void AddDeepLClient_ConfigureOverload_RegistersClient() {
+ using var sp = BuildProvider(s => s.AddDeepLClient(o => o.AuthKey = FakeKey));
+
+ var client = sp.GetService();
+
+ Assert.NotNull(client);
+ }
+
+ [Fact]
+ public void AddDeepLClient_RegistersAllSurfaceInterfaces() {
+ using var sp = BuildProvider(s => s.AddDeepLClient(o => o.AuthKey = FakeKey));
+
+ Assert.NotNull(sp.GetService());
+ Assert.NotNull(sp.GetService());
+ Assert.NotNull(sp.GetService());
+ Assert.NotNull(sp.GetService());
+ }
+
+ [Fact]
+ public void AddDeepLClient_AllInterfacesResolveToSameSingleton() {
+ using var sp = BuildProvider(s => s.AddDeepLClient(o => o.AuthKey = FakeKey));
+
+ var client = sp.GetRequiredService();
+ var translator = sp.GetRequiredService();
+ var writer = sp.GetRequiredService();
+ var glossary = sp.GetRequiredService();
+ var styleRule = sp.GetRequiredService();
+
+ Assert.Same(client, translator);
+ Assert.Same(client, writer);
+ Assert.Same(client, glossary);
+ Assert.Same(client, styleRule);
+ }
+
+ [Fact]
+ public void AddDeepLClient_SingletonLifetime_ReturnsSameInstance() {
+ using var sp = BuildProvider(s => s.AddDeepLClient(o => o.AuthKey = FakeKey));
+
+ var first = sp.GetRequiredService();
+ var second = sp.GetRequiredService();
+
+ Assert.Same(first, second);
+ }
+
+ [Fact]
+ public void AddDeepLClient_MissingAuthKey_ThrowsOnResolve() {
+ using var sp = BuildProvider(s => s.AddDeepLClient(o => o.AuthKey = ""));
+
+ var ex = Assert.Throws(() => sp.GetRequiredService());
+ Assert.Contains("AuthKey", ex.Message);
+ }
+
+ [Fact]
+ public void AddDeepLClient_WhitespaceAuthKey_ThrowsOnResolve() {
+ using var sp = BuildProvider(s => s.AddDeepLClient(o => o.AuthKey = " "));
+
+ Assert.Throws(() => sp.GetRequiredService());
+ }
+
+ [Fact]
+ public void AddDeepLClient_UsesNamedHttpClientFromFactory() {
+ using var sp = BuildProvider(s => s.AddDeepLClient(o => o.AuthKey = FakeKey));
+
+ // Resolving DeepLClient invokes IHttpClientFactory.CreateClient("DeepL") during construction;
+ // if the named client wasn't registered, the resolution would throw.
+ var factory = sp.GetRequiredService();
+ using var namedClient = factory.CreateClient(DeepLOptions.HttpClientName);
+
+ Assert.NotNull(namedClient);
+
+ // And actually resolving DeepLClient (which goes through the factory) must succeed.
+ var client = sp.GetRequiredService();
+ Assert.NotNull(client);
+ }
+
+ [Fact]
+ public async System.Threading.Tasks.Task AddDeepLClient_ServerUrl_PropagatesToClient() {
+ // Construct with a custom server URL and a captured HttpClient we can interrogate
+ // via an ordinary HttpMessageHandler that simply records the request URI.
+ var captured = new UriCapturingHandler();
+
+ var services = new ServiceCollection();
+ services.AddDeepLClient(o => {
+ o.AuthKey = FakeKey;
+ o.ServerUrl = "https://example.invalid/deepl/";
+ });
+
+ // Replace the named HttpClient's primary handler with the capturing one.
+ services.AddHttpClient(DeepLOptions.HttpClientName)
+ .ConfigurePrimaryHttpMessageHandler(() => captured);
+
+ using var sp = services.BuildServiceProvider();
+ var client = sp.GetRequiredService();
+
+ // Fire a request and swallow the resulting exception. We only care that the request
+ // hit the configured ServerUrl.
+ try {
+ await client.GetUsageAsync();
+ } catch {
+ /* expected — the fake handler returns an empty response the client can't parse */
+ }
+
+ Assert.NotNull(captured.LastRequestUri);
+ Assert.StartsWith("https://example.invalid/deepl/", captured.LastRequestUri!.ToString());
+ }
+
+ [Fact]
+ public void AddDeepLClient_Idempotent_SecondCallDoesNotDuplicateRegistrations() {
+ // Consumers sometimes call AddDeepLClient in library extensions plus app startup.
+ // TryAdd* semantics should make the second call a no-op.
+ var services = new ServiceCollection();
+ services.AddDeepLClient(o => o.AuthKey = FakeKey);
+ services.AddDeepLClient(o => o.AuthKey = FakeKey);
+
+ using var sp = services.BuildServiceProvider();
+ var clients = sp.GetServices();
+
+ Assert.Single(clients);
+ }
+
+ [Fact]
+ public void AddDeepLClient_NullServices_Throws() {
+ IServiceCollection? services = null;
+ Assert.Throws(
+ () => services!.AddDeepLClient(o => o.AuthKey = FakeKey));
+ }
+
+ [Fact]
+ public void AddDeepLClient_NullConfigureDelegate_Throws() {
+ var services = new ServiceCollection();
+ Assert.Throws(
+ () => services.AddDeepLClient((Action)null!));
+ }
+
+ // ---------- Configuration overload ----------
+
+ [Fact]
+ public void AddDeepLClient_ConfigurationOverload_BindsFromDefaultSection() {
+ var config = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary {
+ ["DeepL:AuthKey"] = FakeKey,
+ ["DeepL:ServerUrl"] = "https://api.deepl.com/"
+ })
+ .Build();
+
+ using var sp = BuildProvider(s => s.AddDeepLClient(config));
+
+ var opts = sp.GetRequiredService>().Value;
+ Assert.Equal(FakeKey, opts.AuthKey);
+ Assert.Equal("https://api.deepl.com/", opts.ServerUrl);
+ }
+
+ [Fact]
+ public void AddDeepLClient_ConfigurationOverload_AcceptsExplicitSection() {
+ var config = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary {
+ ["Translation:DeepL:AuthKey"] = FakeKey,
+ })
+ .Build();
+
+ using var sp = BuildProvider(s => s.AddDeepLClient(config.GetSection("Translation:DeepL")));
+
+ var opts = sp.GetRequiredService>().Value;
+ Assert.Equal(FakeKey, opts.AuthKey);
+ }
+
+ [Fact]
+ public void AddDeepLClient_ConfigurationOverload_MissingKey_Throws() {
+ var config = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary())
+ .Build();
+
+ using var sp = BuildProvider(s => s.AddDeepLClient(config));
+
+ Assert.Throws(() => sp.GetRequiredService());
+ }
+
+ [Fact]
+ public void AddDeepLClient_ConfigurationOverload_NullConfig_Throws() {
+ var services = new ServiceCollection();
+ Assert.Throws(
+ () => services.AddDeepLClient((IConfiguration)null!));
+ }
+
+ // ---------- Test helpers ----------
+
+ private sealed class UriCapturingHandler : HttpMessageHandler {
+ public Uri? LastRequestUri { get; private set; }
+
+ protected override System.Threading.Tasks.Task SendAsync(
+ HttpRequestMessage request, System.Threading.CancellationToken cancellationToken) {
+ LastRequestUri = request.RequestUri;
+ return System.Threading.Tasks.Task.FromResult(
+ new HttpResponseMessage(System.Net.HttpStatusCode.OK) { Content = new StringContent("{}") });
+ }
+ }
+ }
+}
diff --git a/DeepL.Extensions.DependencyInjection/DeepL.Extensions.DependencyInjection.csproj b/DeepL.Extensions.DependencyInjection/DeepL.Extensions.DependencyInjection.csproj
new file mode 100644
index 0000000..05e1667
--- /dev/null
+++ b/DeepL.Extensions.DependencyInjection/DeepL.Extensions.DependencyInjection.csproj
@@ -0,0 +1,45 @@
+
+
+
+ Microsoft.Extensions.DependencyInjection integration for DeepL.net. Adds AddDeepLClient() to register DeepLClient and its interfaces (ITranslator, IWriter, IGlossaryManager, IStyleRuleManager, IVoiceManager) with an IServiceCollection, routing the underlying HttpClient through IHttpClientFactory.
+ DeepL.Extensions.DependencyInjection
+ 1.20.0
+ 1.20.0
+ 1.20.0.0
+ 1.0.0.0
+ netstandard2.0;net8.0
+ 12
+ enable
+ true
+ nullable
+ DeepL.Extensions.DependencyInjection
+ DeepL SE
+ DeepL SE
+ DeepL.Extensions.DependencyInjection
+ DeepL.Extensions.DependencyInjection
+ deepl;translation;api;dependency-injection;di;aspnetcore
+ icon.png
+ https://www.deepl.com/pro-api
+ https://github.com/DeepLcom/deepl-dotnet
+ MIT
+ README.md
+ Release notes can be found at https://github.com/DeepLcom/deepl-dotnet/blob/main/CHANGELOG.md
+ true
+ true
+ true
+ true
+ ..\DeepL\sgKey.snk
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/DeepL.Extensions.DependencyInjection/DeepLOptions.cs b/DeepL.Extensions.DependencyInjection/DeepLOptions.cs
new file mode 100644
index 0000000..de95143
--- /dev/null
+++ b/DeepL.Extensions.DependencyInjection/DeepLOptions.cs
@@ -0,0 +1,40 @@
+// Copyright 2026 DeepL SE (https://www.deepl.com)
+// Use of this source code is governed by an MIT
+// license that can be found in the LICENSE file.
+
+namespace DeepL.Extensions.DependencyInjection {
+ ///
+ /// Configuration contract for
+ ///
+ /// (and its overloads).
+ /// Typically populated from configuration:
+ ///
+ /// services.AddDeepLClient(builder.Configuration.GetSection("DeepL"));
+ ///
+ /// with a matching appsettings.json section:
+ ///
+ /// "DeepL": {
+ /// "AuthKey": "...",
+ /// "ServerUrl": "https://api.deepl.com"
+ /// }
+ ///
+ ///
+ public sealed class DeepLOptions {
+ /// Default configuration section name ("DeepL") used by the IConfiguration overload.
+ public const string DefaultSectionName = "DeepL";
+
+ ///
+ /// Name used when resolving the underlying via
+ /// . Consumers can call
+ ///
+ /// against this name to layer on additional handlers or policies.
+ ///
+ public const string HttpClientName = "DeepL";
+
+ /// DeepL API auth key. Required.
+ public string AuthKey { get; set; } = string.Empty;
+
+ /// Optional override for the DeepL API server URL (for testing / proxying).
+ public string? ServerUrl { get; set; }
+ }
+}
diff --git a/DeepL.Extensions.DependencyInjection/DeepLServiceCollectionExtensions.cs b/DeepL.Extensions.DependencyInjection/DeepLServiceCollectionExtensions.cs
new file mode 100644
index 0000000..7ff6b8f
--- /dev/null
+++ b/DeepL.Extensions.DependencyInjection/DeepLServiceCollectionExtensions.cs
@@ -0,0 +1,117 @@
+// Copyright 2026 DeepL SE (https://www.deepl.com)
+// Use of this source code is governed by an MIT
+// license that can be found in the LICENSE file.
+
+using System;
+using System.Net.Http;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Options;
+
+namespace DeepL.Extensions.DependencyInjection {
+ ///
+ /// Extension methods on for registering
+ /// (and its surface interfaces) into a dependency injection container.
+ ///
+ ///
+ ///
+ /// // Bind from configuration section "DeepL"
+ /// builder.Services.AddDeepLClient(builder.Configuration);
+ ///
+ /// // Configure inline
+ /// builder.Services.AddDeepLClient(o => {
+ /// o.AuthKey = "your-key-here";
+ /// o.ServerUrl = "https://api.deepl.com";
+ /// });
+ ///
+ /// // Consume via constructor injection
+ /// public class TranslationHandler(ITranslator translator) { ... }
+ ///
+ ///
+ public static class DeepLServiceCollectionExtensions {
+ ///
+ /// Registers as a singleton, routed through .
+ /// Consumers can then inject the narrowest interface they need
+ /// (, , ,
+ /// ).
+ ///
+ /// The service collection.
+ /// Delegate to configure .
+ /// The original for chaining.
+ public static IServiceCollection AddDeepLClient(
+ this IServiceCollection services,
+ Action configure) {
+ if (services == null) throw new ArgumentNullException(nameof(services));
+ if (configure == null) throw new ArgumentNullException(nameof(configure));
+
+ services.AddOptions()
+ .Configure(configure)
+ .Validate(o => !string.IsNullOrWhiteSpace(o.AuthKey), "DeepLOptions.AuthKey must be set.");
+
+ RegisterCore(services);
+ return services;
+ }
+
+ ///
+ /// Registers as a singleton, binding from the
+ /// supplied configuration. Defaults to the section
+ /// ("DeepL") unless an explicit section is passed in.
+ ///
+ /// The service collection.
+ ///
+ /// Either a root (the "DeepL" section is read) or a specific
+ /// containing the options.
+ ///
+ /// The original for chaining.
+ public static IServiceCollection AddDeepLClient(
+ this IServiceCollection services,
+ IConfiguration configuration) {
+ if (services == null) throw new ArgumentNullException(nameof(services));
+ if (configuration == null) throw new ArgumentNullException(nameof(configuration));
+
+ var section = configuration is IConfigurationSection s
+ ? s
+ : configuration.GetSection(DeepLOptions.DefaultSectionName);
+
+ services.AddOptions()
+ .Bind(section)
+ .Validate(o => !string.IsNullOrWhiteSpace(o.AuthKey), "DeepLOptions.AuthKey must be set.");
+
+ RegisterCore(services);
+ return services;
+ }
+
+ ///
+ /// Common registration shared by both AddDeepLClient overloads. Registers:
+ /// the named , the singleton,
+ /// and forwarders for every surface interface implements.
+ ///
+ private static void RegisterCore(IServiceCollection services) {
+ services.AddHttpClient(DeepLOptions.HttpClientName);
+
+ // DeepLClient is documented as thread-safe; singleton is the correct lifetime.
+ services.TryAddSingleton(sp => {
+ var opts = sp.GetRequiredService>().Value;
+ var httpClientFactory = sp.GetRequiredService();
+
+ var clientOptions = new DeepLClientOptions {
+ ServerUrl = opts.ServerUrl,
+ ClientFactory = () => new HttpClientAndDisposeFlag {
+ HttpClient = httpClientFactory.CreateClient(DeepLOptions.HttpClientName),
+ // IHttpClientFactory owns the HttpClient lifetime, not DeepLClient.
+ DisposeClient = false,
+ },
+ };
+
+ return new DeepLClient(opts.AuthKey, clientOptions);
+ });
+
+ // Expose every DeepLClient-implemented interface as resolvable against the same singleton.
+ services.TryAddSingleton(sp => sp.GetRequiredService());
+ services.TryAddSingleton(sp => sp.GetRequiredService());
+ services.TryAddSingleton(sp => sp.GetRequiredService());
+ services.TryAddSingleton(sp => sp.GetRequiredService());
+ }
+ }
+}
diff --git a/DeepL.Extensions.DependencyInjection/README.md b/DeepL.Extensions.DependencyInjection/README.md
new file mode 100644
index 0000000..3db21b3
--- /dev/null
+++ b/DeepL.Extensions.DependencyInjection/README.md
@@ -0,0 +1,79 @@
+# DeepL.Extensions.DependencyInjection
+
+`Microsoft.Extensions.DependencyInjection` integration for [DeepL.net](https://www.nuget.org/packages/DeepL.net).
+
+## Install
+
+```
+dotnet add package DeepL.Extensions.DependencyInjection
+```
+
+Pulls in `DeepL.net` transitively.
+
+## Usage
+
+### Configure inline
+
+```csharp
+using DeepL;
+using DeepL.Extensions.DependencyInjection;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.Services.AddDeepLClient(options => {
+ options.AuthKey = builder.Configuration["DeepL:AuthKey"]!;
+ options.ServerUrl = "https://api.deepl.com"; // optional
+});
+```
+
+### Bind from configuration
+
+```json
+// appsettings.json
+{
+ "DeepL": {
+ "AuthKey": "your-key-here",
+ "ServerUrl": "https://api.deepl.com"
+ }
+}
+```
+
+```csharp
+// Binds from the "DeepL" section by default
+builder.Services.AddDeepLClient(builder.Configuration);
+
+// Or pass a specific section
+builder.Services.AddDeepLClient(builder.Configuration.GetSection("Translation:DeepL"));
+```
+
+### Inject what you need
+
+Register once, inject the narrowest interface:
+
+```csharp
+app.MapPost("/translate", async (ITranslator translator, string text, string target)
+ => await translator.Translate(text).To(target));
+
+// In services: constructor-inject IWriter, IGlossaryManager, IStyleRuleManager, IVoiceManager
+// or the full DeepLClient if you need multiple surfaces.
+```
+
+## What the registration does
+
+- Registers `DeepLClient` as a **singleton** (the client is documented thread-safe).
+- Forwards `ITranslator`, `IWriter`, `IGlossaryManager`, `IStyleRuleManager` to the same singleton.
+- Routes the underlying `HttpClient` through `IHttpClientFactory` with the named client `"DeepL"`, so you can layer on your own handlers:
+
+```csharp
+builder.Services.AddDeepLClient(o => o.AuthKey = key);
+
+builder.Services.AddHttpClient(DeepLOptions.HttpClientName)
+ .AddHttpMessageHandler()
+ .AddStandardResilienceHandler();
+```
+
+- Validates `AuthKey` via `IValidateOptions<>`, so a missing key surfaces at application start rather than on first translation.
+
+## Versioning
+
+Versions lockstep with `DeepL.net`. Upgrading the integration package always pulls in a matching main-library version.
diff --git a/DeepL.net.sln b/DeepL.net.sln
index 34992b7..8215e56 100644
--- a/DeepL.net.sln
+++ b/DeepL.net.sln
@@ -4,19 +4,70 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DeepL", "DeepL\DeepL.csproj
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DeepLTests", "DeepLTests\DeepLTests.csproj", "{3582DAA3-A216-4D58-83C8-BA91B4EE2260}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DeepL.Extensions.DependencyInjection", "DeepL.Extensions.DependencyInjection\DeepL.Extensions.DependencyInjection.csproj", "{C814C7E4-00FC-4654-9C90-4FB73906B76C}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DeepL.Extensions.DependencyInjection.Tests", "DeepL.Extensions.DependencyInjection.Tests\DeepL.Extensions.DependencyInjection.Tests.csproj", "{E0936C18-BDF6-4927-B59F-3DB79F32CCCE}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{87DDF1B7-2007-4E90-BDF3-6F6AE465A853}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{87DDF1B7-2007-4E90-BDF3-6F6AE465A853}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {87DDF1B7-2007-4E90-BDF3-6F6AE465A853}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {87DDF1B7-2007-4E90-BDF3-6F6AE465A853}.Debug|x64.Build.0 = Debug|Any CPU
+ {87DDF1B7-2007-4E90-BDF3-6F6AE465A853}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {87DDF1B7-2007-4E90-BDF3-6F6AE465A853}.Debug|x86.Build.0 = Debug|Any CPU
{87DDF1B7-2007-4E90-BDF3-6F6AE465A853}.Release|Any CPU.ActiveCfg = Release|Any CPU
{87DDF1B7-2007-4E90-BDF3-6F6AE465A853}.Release|Any CPU.Build.0 = Release|Any CPU
+ {87DDF1B7-2007-4E90-BDF3-6F6AE465A853}.Release|x64.ActiveCfg = Release|Any CPU
+ {87DDF1B7-2007-4E90-BDF3-6F6AE465A853}.Release|x64.Build.0 = Release|Any CPU
+ {87DDF1B7-2007-4E90-BDF3-6F6AE465A853}.Release|x86.ActiveCfg = Release|Any CPU
+ {87DDF1B7-2007-4E90-BDF3-6F6AE465A853}.Release|x86.Build.0 = Release|Any CPU
{3582DAA3-A216-4D58-83C8-BA91B4EE2260}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3582DAA3-A216-4D58-83C8-BA91B4EE2260}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {3582DAA3-A216-4D58-83C8-BA91B4EE2260}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {3582DAA3-A216-4D58-83C8-BA91B4EE2260}.Debug|x64.Build.0 = Debug|Any CPU
+ {3582DAA3-A216-4D58-83C8-BA91B4EE2260}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {3582DAA3-A216-4D58-83C8-BA91B4EE2260}.Debug|x86.Build.0 = Debug|Any CPU
{3582DAA3-A216-4D58-83C8-BA91B4EE2260}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3582DAA3-A216-4D58-83C8-BA91B4EE2260}.Release|Any CPU.Build.0 = Release|Any CPU
+ {3582DAA3-A216-4D58-83C8-BA91B4EE2260}.Release|x64.ActiveCfg = Release|Any CPU
+ {3582DAA3-A216-4D58-83C8-BA91B4EE2260}.Release|x64.Build.0 = Release|Any CPU
+ {3582DAA3-A216-4D58-83C8-BA91B4EE2260}.Release|x86.ActiveCfg = Release|Any CPU
+ {3582DAA3-A216-4D58-83C8-BA91B4EE2260}.Release|x86.Build.0 = Release|Any CPU
+ {C814C7E4-00FC-4654-9C90-4FB73906B76C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {C814C7E4-00FC-4654-9C90-4FB73906B76C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C814C7E4-00FC-4654-9C90-4FB73906B76C}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {C814C7E4-00FC-4654-9C90-4FB73906B76C}.Debug|x64.Build.0 = Debug|Any CPU
+ {C814C7E4-00FC-4654-9C90-4FB73906B76C}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {C814C7E4-00FC-4654-9C90-4FB73906B76C}.Debug|x86.Build.0 = Debug|Any CPU
+ {C814C7E4-00FC-4654-9C90-4FB73906B76C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {C814C7E4-00FC-4654-9C90-4FB73906B76C}.Release|Any CPU.Build.0 = Release|Any CPU
+ {C814C7E4-00FC-4654-9C90-4FB73906B76C}.Release|x64.ActiveCfg = Release|Any CPU
+ {C814C7E4-00FC-4654-9C90-4FB73906B76C}.Release|x64.Build.0 = Release|Any CPU
+ {C814C7E4-00FC-4654-9C90-4FB73906B76C}.Release|x86.ActiveCfg = Release|Any CPU
+ {C814C7E4-00FC-4654-9C90-4FB73906B76C}.Release|x86.Build.0 = Release|Any CPU
+ {E0936C18-BDF6-4927-B59F-3DB79F32CCCE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {E0936C18-BDF6-4927-B59F-3DB79F32CCCE}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {E0936C18-BDF6-4927-B59F-3DB79F32CCCE}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {E0936C18-BDF6-4927-B59F-3DB79F32CCCE}.Debug|x64.Build.0 = Debug|Any CPU
+ {E0936C18-BDF6-4927-B59F-3DB79F32CCCE}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {E0936C18-BDF6-4927-B59F-3DB79F32CCCE}.Debug|x86.Build.0 = Debug|Any CPU
+ {E0936C18-BDF6-4927-B59F-3DB79F32CCCE}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {E0936C18-BDF6-4927-B59F-3DB79F32CCCE}.Release|Any CPU.Build.0 = Release|Any CPU
+ {E0936C18-BDF6-4927-B59F-3DB79F32CCCE}.Release|x64.ActiveCfg = Release|Any CPU
+ {E0936C18-BDF6-4927-B59F-3DB79F32CCCE}.Release|x64.Build.0 = Release|Any CPU
+ {E0936C18-BDF6-4927-B59F-3DB79F32CCCE}.Release|x86.ActiveCfg = Release|Any CPU
+ {E0936C18-BDF6-4927-B59F-3DB79F32CCCE}.Release|x86.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
EndGlobalSection
EndGlobal
diff --git a/DeepL/DeepL.csproj b/DeepL/DeepL.csproj
index f6319aa..6df9fa4 100644
--- a/DeepL/DeepL.csproj
+++ b/DeepL/DeepL.csproj
@@ -7,8 +7,8 @@
1.20.0
1.20.0.0
1.0.0.0
- net5.0;netstandard2.0
- 8
+ netstandard2.0;net8.0
+ 12
enable
true
nullable
@@ -32,8 +32,9 @@
-
-
+
+
+
diff --git a/DeepL/FluentDocumentTranslation.cs b/DeepL/FluentDocumentTranslation.cs
new file mode 100644
index 0000000..9c1e192
--- /dev/null
+++ b/DeepL/FluentDocumentTranslation.cs
@@ -0,0 +1,497 @@
+// Copyright 2026 DeepL SE (https://www.deepl.com)
+// Use of this source code is governed by an MIT
+// license that can be found in the LICENSE file.
+
+using System;
+using System.IO;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using DeepL.Model;
+
+namespace DeepL {
+ ///
+ /// Fluent entry points for document translation on .
+ ///
+ ///
+ ///
+ /// // One-shot: upload, wait, download
+ /// await translator
+ /// .TranslateDocument(new FileInfo("input.docx"))
+ /// .To("de")
+ /// .From("en")
+ /// .WithFormality(Formality.More)
+ /// .WithGlossary(glossary)
+ /// .SaveTo(new FileInfo("output.docx"));
+ ///
+ /// // Split flow
+ /// var handle = await translator.TranslateDocument(fileInfo).To("de").UploadAsync();
+ /// await translator.Document(handle).WaitUntilDoneAsync();
+ /// await translator.Document(handle).DownloadToAsync(new FileInfo("output.docx"));
+ ///
+ ///
+ public static class FluentDocumentTranslationExtensions {
+ /// Starts a fluent document translation from a input.
+ public static DocumentTranslationBuilder TranslateDocument(
+ this ITranslator translator, FileInfo inputFileInfo) {
+ if (translator == null) throw new ArgumentNullException(nameof(translator));
+ if (inputFileInfo == null) throw new ArgumentNullException(nameof(inputFileInfo));
+ return new DocumentTranslationBuilder(translator, inputFileInfo);
+ }
+
+ /// Starts a fluent document translation from a input.
+ public static DocumentTranslationBuilder TranslateDocument(
+ this ITranslator translator, Stream inputStream, string inputFileName) {
+ if (translator == null) throw new ArgumentNullException(nameof(translator));
+ if (inputStream == null) throw new ArgumentNullException(nameof(inputStream));
+ if (string.IsNullOrWhiteSpace(inputFileName)) {
+ throw new ArgumentException($"Parameter {nameof(inputFileName)} must not be empty", nameof(inputFileName));
+ }
+
+ return new DocumentTranslationBuilder(translator, inputStream, inputFileName);
+ }
+
+ /// Returns a fluent reference for an in-progress document translation.
+ public static DocumentRef Document(this ITranslator translator, DocumentHandle handle) {
+ if (translator == null) throw new ArgumentNullException(nameof(translator));
+ return new DocumentRef(translator, handle);
+ }
+ }
+
+ ///
+ /// Fluent builder for a document translation. Supports both the one-shot flow
+ /// ( / ) and the split
+ /// upload/status/download flow ().
+ ///
+ public sealed class DocumentTranslationBuilder {
+ private readonly ITranslator _translator;
+ private readonly FileInfo? _inputFileInfo;
+ private readonly Stream? _inputStream;
+ private readonly string? _inputFileName;
+ private readonly DocumentTranslateOptions _options = new DocumentTranslateOptions();
+ private string? _sourceLanguageCode;
+ private string? _targetLanguageCode;
+ private CancellationToken _cancellationToken;
+ private IProgress? _progress;
+
+ internal DocumentTranslationBuilder(ITranslator translator, FileInfo inputFileInfo) {
+ _translator = translator;
+ _inputFileInfo = inputFileInfo;
+ }
+
+ internal DocumentTranslationBuilder(ITranslator translator, Stream inputStream, string inputFileName) {
+ _translator = translator;
+ _inputStream = inputStream;
+ _inputFileName = inputFileName;
+ }
+
+ /// Sets the target language code.
+ public DocumentTranslationBuilder To(string targetLanguageCode) {
+ _targetLanguageCode = targetLanguageCode ?? throw new ArgumentNullException(nameof(targetLanguageCode));
+ return this;
+ }
+
+ /// Sets the source language code. Pass null to rely on auto-detection.
+ public DocumentTranslationBuilder From(string? sourceLanguageCode) {
+ _sourceLanguageCode = sourceLanguageCode;
+ return this;
+ }
+
+ /// Copies fields from the supplied options object onto this builder.
+ public DocumentTranslationBuilder Using(DocumentTranslateOptions options) {
+ if (options == null) throw new ArgumentNullException(nameof(options));
+ _options.Formality = options.Formality;
+ _options.GlossaryId = options.GlossaryId;
+ _options.EnableDocumentMinification = options.EnableDocumentMinification;
+ _options.OutputFormat = options.OutputFormat;
+ return this;
+ }
+
+ /// Mutates the options via the supplied delegate.
+ public DocumentTranslationBuilder Using(Action configure) {
+ if (configure == null) throw new ArgumentNullException(nameof(configure));
+ configure(_options);
+ return this;
+ }
+
+ /// Sets the formality level.
+ public DocumentTranslationBuilder WithFormality(Formality formality) {
+ _options.Formality = formality;
+ return this;
+ }
+
+ /// Uses the supplied glossary.
+ public DocumentTranslationBuilder WithGlossary(GlossaryInfo glossary) {
+ if (glossary == null) throw new ArgumentNullException(nameof(glossary));
+ _options.GlossaryId = glossary.GlossaryId;
+ return this;
+ }
+
+ /// Uses the supplied multilingual glossary.
+ public DocumentTranslationBuilder WithGlossary(MultilingualGlossaryInfo glossary) {
+ if (glossary == null) throw new ArgumentNullException(nameof(glossary));
+ _options.GlossaryId = glossary.GlossaryId;
+ return this;
+ }
+
+ /// Uses the glossary identified by .
+ public DocumentTranslationBuilder WithGlossaryId(string glossaryId) {
+ _options.GlossaryId = glossaryId ?? throw new ArgumentNullException(nameof(glossaryId));
+ return this;
+ }
+
+ /// Enables document minification for supported formats.
+ ///
+ /// Minification is only applied when translating from a source to a
+ /// destination () without a progress
+ /// callback. Any other path (stream input, stream output, or )
+ /// will throw at execution time when minification
+ /// is enabled.
+ ///
+ public DocumentTranslationBuilder WithMinification(bool enable = true) {
+ _options.EnableDocumentMinification = enable;
+ return this;
+ }
+
+ /// Requests a specific output format (e.g. "docx"). Defaults to the input file format.
+ public DocumentTranslationBuilder WithOutputFormat(string outputFormat) {
+ _options.OutputFormat = outputFormat ?? throw new ArgumentNullException(nameof(outputFormat));
+ return this;
+ }
+
+ ///
+ /// Associates a cancellation token with the eventual request(s).
+ /// For ad-hoc cancellation without a pre-built ,
+ /// prefer calling on the handle returned from
+ /// / .
+ ///
+ public DocumentTranslationBuilder WithCancellation(CancellationToken cancellationToken) {
+ _cancellationToken = cancellationToken;
+ return this;
+ }
+
+ ///
+ /// Attaches a progress callback that is invoked each time the document status is polled
+ /// during the wait phase (between upload and download). Useful for UI progress indicators,
+ /// structured logging, or webhook emissions.
+ ///
+ ///
+ /// When a progress callback is attached, the fluent builder takes its own orchestration
+ /// path (upload → poll → download) instead of delegating to
+ /// .
+ /// Document minification is not supported on the progress path;
+ /// if both are required, fall back to configuring a and
+ /// awaiting without progress.
+ ///
+ public DocumentTranslationBuilder WithProgress(IProgress progress) {
+ _progress = progress ?? throw new ArgumentNullException(nameof(progress));
+ return this;
+ }
+
+ ///
+ /// Uploads, waits, and downloads the translated document to .
+ /// Returns a that is directly awaitable AND supports
+ /// for fluent, ad-hoc cancellation.
+ ///
+ public DocumentTranslationJob SaveTo(FileInfo outputFileInfo) {
+ if (outputFileInfo == null) throw new ArgumentNullException(nameof(outputFileInfo));
+ EnsureTargetLanguage();
+ return Start(outputFileInfo, outputStream: null);
+ }
+
+ ///
+ /// Uploads, waits, and downloads the translated document into .
+ /// Returns a that is directly awaitable AND supports
+ /// .
+ ///
+ public DocumentTranslationJob SaveTo(Stream outputStream) {
+ if (outputStream == null) throw new ArgumentNullException(nameof(outputStream));
+ EnsureTargetLanguage();
+ return Start(outputFile: null, outputStream);
+ }
+
+ ///
+ /// Uploads the document and returns a without waiting for completion.
+ /// Use to track and download the result.
+ ///
+ public Task UploadAsync() {
+ EnsureTargetLanguage();
+ if (_inputFileInfo != null) {
+ return _translator.TranslateDocumentUploadAsync(
+ _inputFileInfo, _sourceLanguageCode, _targetLanguageCode!, _options, _cancellationToken);
+ }
+
+ return _translator.TranslateDocumentUploadAsync(
+ _inputStream!,
+ _inputFileName!,
+ _sourceLanguageCode,
+ _targetLanguageCode!,
+ _options,
+ _cancellationToken);
+ }
+
+ private DocumentTranslationJob Start(FileInfo? outputFile, Stream? outputStream) {
+ if (_options.EnableDocumentMinification) {
+ // Minification is only honored by the FileInfo→FileInfo library overload. Fail fast on
+ // any other path so callers get a clear error instead of silently non-minified output.
+ bool fileToFile = _inputFileInfo != null && outputFile != null;
+ if (!fileToFile || _progress != null) {
+ throw new InvalidOperationException(
+ "Document minification (WithMinification) is only supported when translating " +
+ "from a FileInfo source to a FileInfo destination without a progress callback. " +
+ "Use TranslateDocument(FileInfo).SaveTo(FileInfo) without WithProgress().");
+ }
+ }
+
+ var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(_cancellationToken);
+ var task = RunAsync(outputFile, outputStream, linkedCts.Token);
+ // Dispose the CTS when the job completes, regardless of outcome.
+ _ = task.ContinueWith(
+ _ => linkedCts.Dispose(),
+ CancellationToken.None,
+ TaskContinuationOptions.ExecuteSynchronously,
+ TaskScheduler.Default);
+ return new DocumentTranslationJob(task, linkedCts);
+ }
+
+ private Task RunAsync(FileInfo? outputFile, Stream? outputStream, CancellationToken ct) {
+ // Without progress: delegate to the library's existing orchestration so we inherit its
+ // DocumentTranslationException wrapping AND document-minification support.
+ if (_progress == null) {
+ return outputFile != null
+ ? RunViaLibraryToFileAsync(outputFile, ct)
+ : RunViaLibraryToStreamAsync(outputStream!, ct);
+ }
+ // With progress: run upload → poll-with-callbacks → download in this layer.
+ return RunWithProgressAsync(outputFile, outputStream, ct);
+ }
+
+ private async Task RunViaLibraryToFileAsync(FileInfo outputFileInfo, CancellationToken ct) {
+ if (_inputFileInfo != null) {
+ await _translator.TranslateDocumentAsync(
+ _inputFileInfo,
+ outputFileInfo,
+ _sourceLanguageCode,
+ _targetLanguageCode!,
+ _options,
+ ct)
+ .ConfigureAwait(false);
+ return;
+ }
+
+ using var outputFile = outputFileInfo.Open(FileMode.CreateNew, FileAccess.Write);
+ try {
+ await _translator.TranslateDocumentAsync(
+ _inputStream!,
+ _inputFileName!,
+ outputFile,
+ _sourceLanguageCode,
+ _targetLanguageCode!,
+ _options,
+ ct)
+ .ConfigureAwait(false);
+ } catch {
+ try { outputFileInfo.Delete(); } catch { /* ignored */ }
+ throw;
+ }
+ }
+
+ private Task RunViaLibraryToStreamAsync(Stream outputStream, CancellationToken ct) {
+ if (_inputFileInfo != null) {
+ return RunFromFileToStreamViaLibraryAsync(outputStream, ct);
+ }
+
+ return _translator.TranslateDocumentAsync(
+ _inputStream!,
+ _inputFileName!,
+ outputStream,
+ _sourceLanguageCode,
+ _targetLanguageCode!,
+ _options,
+ ct);
+ }
+
+ private async Task RunFromFileToStreamViaLibraryAsync(Stream outputStream, CancellationToken ct) {
+ using var inputFile = _inputFileInfo!.OpenRead();
+ await _translator.TranslateDocumentAsync(
+ inputFile,
+ _inputFileInfo.Name,
+ outputStream,
+ _sourceLanguageCode,
+ _targetLanguageCode!,
+ _options,
+ ct)
+ .ConfigureAwait(false);
+ }
+
+ private async Task RunWithProgressAsync(
+ FileInfo? outputFile, Stream? outputStream, CancellationToken ct) {
+ FileStream? openedOutputFile = null;
+ try {
+ // Upload
+ var handle = await UploadCoreAsync(ct).ConfigureAwait(false);
+
+ // Wait (with progress)
+ await DocumentPolling.WaitAsync(_translator, handle, _progress, ct).ConfigureAwait(false);
+
+ // Download
+ if (outputFile != null) {
+ openedOutputFile = outputFile.Open(FileMode.CreateNew, FileAccess.Write);
+ await _translator.TranslateDocumentDownloadAsync(handle, openedOutputFile, ct)
+ .ConfigureAwait(false);
+ } else {
+ await _translator.TranslateDocumentDownloadAsync(handle, outputStream!, ct)
+ .ConfigureAwait(false);
+ }
+ } catch {
+ // Mirror the library's cleanup behavior: remove the half-written output file on error.
+ if (outputFile != null) {
+ openedOutputFile?.Dispose();
+ try { outputFile.Refresh(); if (outputFile.Exists) outputFile.Delete(); } catch { /* ignored */ }
+ }
+ throw;
+ } finally {
+ openedOutputFile?.Dispose();
+ }
+ }
+
+ private Task UploadCoreAsync(CancellationToken ct) {
+ if (_inputFileInfo != null) {
+ return _translator.TranslateDocumentUploadAsync(
+ _inputFileInfo, _sourceLanguageCode, _targetLanguageCode!, _options, ct);
+ }
+ return _translator.TranslateDocumentUploadAsync(
+ _inputStream!, _inputFileName!, _sourceLanguageCode, _targetLanguageCode!, _options, ct);
+ }
+
+ private void EnsureTargetLanguage() {
+ if (_targetLanguageCode == null) {
+ throw new InvalidOperationException(
+ "Target language is required. Call .To(targetLanguageCode) before uploading / saving.");
+ }
+ }
+ }
+
+ ///
+ /// Handle to a running document-translation operation. Directly awaitable, and supports
+ /// so callers can keep the fluent style instead of plumbing a
+ /// through by hand.
+ ///
+ ///
+ ///
+ /// var job = translator.TranslateDocument(input).To("de").SaveTo(output);
+ /// // ...time passes, user clicks Cancel in UI...
+ /// job.Cancel();
+ /// try { await job; } catch (OperationCanceledException) { /* handled */ }
+ ///
+ ///
+ public sealed class DocumentTranslationJob {
+ private readonly Task _task;
+ private readonly CancellationTokenSource _cts;
+
+ internal DocumentTranslationJob(Task task, CancellationTokenSource cts) {
+ _task = task;
+ _cts = cts;
+ }
+
+ /// The underlying representing the upload → poll → download flow.
+ public Task Task => _task;
+
+ /// true once the job has completed (successfully, failed, or cancelled).
+ public bool IsCompleted => _task.IsCompleted;
+
+ ///
+ /// Signals cancellation to the in-flight job. Safe to call after completion (no-op).
+ /// Awaiting the job afterwards will typically surface an .
+ ///
+ public void Cancel() {
+ try { _cts.Cancel(); } catch (ObjectDisposedException) { /* already finished */ }
+ }
+
+ /// Enables await job — waits until upload/poll/download finishes or is cancelled.
+ public TaskAwaiter GetAwaiter() => _task.GetAwaiter();
+
+ /// Implicit conversion so the job can be passed wherever a is expected.
+ public static implicit operator Task(DocumentTranslationJob job) =>
+ job?._task ?? throw new ArgumentNullException(nameof(job));
+ }
+
+ /// Shared poll loop used by and .
+ internal static class DocumentPolling {
+ internal static async Task WaitAsync(
+ ITranslator translator,
+ DocumentHandle handle,
+ IProgress? progress,
+ CancellationToken cancellationToken) {
+ var status = await translator.TranslateDocumentStatusAsync(handle, cancellationToken)
+ .ConfigureAwait(false);
+ progress?.Report(status);
+ while (status.Ok && !status.Done) {
+ await Task.Delay(CalculatePollDelay(status.SecondsRemaining), cancellationToken)
+ .ConfigureAwait(false);
+ status = await translator.TranslateDocumentStatusAsync(handle, cancellationToken)
+ .ConfigureAwait(false);
+ progress?.Report(status);
+ }
+ if (!status.Ok) {
+ throw new DeepLException(status.ErrorMessage ?? "Unknown error");
+ }
+ }
+
+ // Mirrors the library's internal CalculateDocumentWaitTime heuristic without reaching into it:
+ // fall back to a 5-second floor when the server gives no estimate, clamp to [1, 60] seconds.
+ private static TimeSpan CalculatePollDelay(int? secondsRemaining) {
+ var seconds = secondsRemaining.GetValueOrDefault(5);
+ if (seconds < 1) seconds = 1;
+ if (seconds > 60) seconds = 60;
+ return TimeSpan.FromSeconds(seconds);
+ }
+ }
+
+ /// Fluent reference for an in-progress document translation identified by a .
+ public sealed class DocumentRef {
+ private readonly ITranslator _translator;
+
+ internal DocumentRef(ITranslator translator, DocumentHandle handle) {
+ _translator = translator;
+ Handle = handle;
+ }
+
+ public DocumentHandle Handle { get; }
+
+ /// Retrieves the current status of the translation.
+ public Task GetStatusAsync(CancellationToken cancellationToken = default) =>
+ _translator.TranslateDocumentStatusAsync(Handle, cancellationToken);
+
+ ///
+ /// Polls until the translation is done or fails. Delegates to the library's built-in
+ /// .
+ ///
+ public Task WaitUntilDoneAsync(CancellationToken cancellationToken = default) =>
+ _translator.TranslateDocumentWaitUntilDoneAsync(Handle, cancellationToken);
+
+ ///
+ /// Polls until the translation is done or fails, reporting each status tick through
+ /// . Useful for UI progress indicators, structured logging,
+ /// or webhook emissions during the wait phase.
+ ///
+ public Task WaitUntilDoneAsync(
+ IProgress progress,
+ CancellationToken cancellationToken = default) {
+ if (progress == null) throw new ArgumentNullException(nameof(progress));
+ return DocumentPolling.WaitAsync(_translator, Handle, progress, cancellationToken);
+ }
+
+ /// Downloads the translated document to a file.
+ public Task DownloadToAsync(FileInfo outputFileInfo, CancellationToken cancellationToken = default) {
+ if (outputFileInfo == null) throw new ArgumentNullException(nameof(outputFileInfo));
+ return _translator.TranslateDocumentDownloadAsync(Handle, outputFileInfo, cancellationToken);
+ }
+
+ /// Downloads the translated document to a stream.
+ public Task DownloadToAsync(Stream outputStream, CancellationToken cancellationToken = default) {
+ if (outputStream == null) throw new ArgumentNullException(nameof(outputStream));
+ return _translator.TranslateDocumentDownloadAsync(Handle, outputStream, cancellationToken);
+ }
+ }
+}
diff --git a/DeepL/FluentGlossary.cs b/DeepL/FluentGlossary.cs
new file mode 100644
index 0000000..b203486
--- /dev/null
+++ b/DeepL/FluentGlossary.cs
@@ -0,0 +1,333 @@
+// Copyright 2026 DeepL SE (https://www.deepl.com)
+// Use of this source code is governed by an MIT
+// license that can be found in the LICENSE file.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using DeepL.Model;
+
+namespace DeepL {
+ ///
+ /// Fluent entry points for glossary management on .
+ ///
+ ///
+ ///
+ /// // Create
+ /// var glossary = await client
+ /// .CreateGlossary("My glossary")
+ /// .WithDictionary("en", "de", entries)
+ /// .WithDictionary("de", "en", reverseEntries)
+ /// .CreateAsync();
+ ///
+ /// // Inspect / modify
+ /// var info = await client.Glossary(id).GetAsync();
+ /// await client.Glossary(id).RenameAsync("new name");
+ /// await client.Glossary(id).DeleteAsync();
+ ///
+ /// // Dictionary-level operations
+ /// var entries = await client.Glossary(id).Dictionary("en", "de").GetEntriesAsync();
+ /// await client.Glossary(id).Dictionary("en", "de").ReplaceAsync(newEntries);
+ /// await client.Glossary(id).Dictionary("en", "de").MergeAsync(extraEntries);
+ /// await client.Glossary(id).Dictionary("en", "de").DeleteAsync();
+ ///
+ ///
+ public static class FluentGlossaryExtensions {
+ /// Lists all glossaries on the account.
+ public static Task ListGlossariesAsync(
+ this IGlossaryManager manager,
+ CancellationToken cancellationToken = default) {
+ if (manager == null) throw new ArgumentNullException(nameof(manager));
+ return manager.ListMultilingualGlossariesAsync(cancellationToken);
+ }
+
+ /// Returns a fluent reference to the glossary with the given ID.
+ public static GlossaryRef Glossary(this IGlossaryManager manager, string glossaryId) {
+ if (manager == null) throw new ArgumentNullException(nameof(manager));
+ if (string.IsNullOrWhiteSpace(glossaryId)) {
+ throw new ArgumentException($"Parameter {nameof(glossaryId)} must not be empty", nameof(glossaryId));
+ }
+
+ return new GlossaryRef(manager, glossaryId);
+ }
+
+ /// Returns a fluent reference for the supplied glossary.
+ public static GlossaryRef Glossary(this IGlossaryManager manager, MultilingualGlossaryInfo glossary) {
+ if (manager == null) throw new ArgumentNullException(nameof(manager));
+ if (glossary == null) throw new ArgumentNullException(nameof(glossary));
+ return new GlossaryRef(manager, glossary.GlossaryId);
+ }
+
+ /// Begins a fluent glossary-creation builder.
+ public static GlossaryCreateBuilder CreateGlossary(this IGlossaryManager manager, string name) {
+ if (manager == null) throw new ArgumentNullException(nameof(manager));
+ if (string.IsNullOrWhiteSpace(name)) {
+ throw new ArgumentException($"Parameter {nameof(name)} must not be empty", nameof(name));
+ }
+
+ return new GlossaryCreateBuilder(manager, name);
+ }
+ }
+
+ /// Fluent reference for an existing glossary. Operations execute when awaited.
+ public sealed class GlossaryRef {
+ private readonly IGlossaryManager _manager;
+
+ internal GlossaryRef(IGlossaryManager manager, string glossaryId) {
+ _manager = manager;
+ GlossaryId = glossaryId;
+ }
+
+ /// ID of the glossary this reference targets.
+ public string GlossaryId { get; }
+
+ /// Retrieves glossary metadata.
+ public Task GetAsync(CancellationToken cancellationToken = default) =>
+ _manager.GetMultilingualGlossaryAsync(GlossaryId, cancellationToken);
+
+ /// Renames the glossary.
+ public Task RenameAsync(string name, CancellationToken cancellationToken = default) {
+ if (string.IsNullOrWhiteSpace(name)) {
+ throw new ArgumentException($"Parameter {nameof(name)} must not be empty", nameof(name));
+ }
+
+ return _manager.UpdateMultilingualGlossaryNameAsync(GlossaryId, name, cancellationToken);
+ }
+
+ /// Deletes the glossary.
+ public Task DeleteAsync(CancellationToken cancellationToken = default) =>
+ _manager.DeleteMultilingualGlossaryAsync(GlossaryId, cancellationToken);
+
+ /// Returns a fluent reference to a dictionary inside this glossary.
+ public GlossaryDictionaryRef Dictionary(string sourceLanguageCode, string targetLanguageCode) {
+ if (string.IsNullOrWhiteSpace(sourceLanguageCode)) {
+ throw new ArgumentException(
+ $"Parameter {nameof(sourceLanguageCode)} must not be empty", nameof(sourceLanguageCode));
+ }
+
+ if (string.IsNullOrWhiteSpace(targetLanguageCode)) {
+ throw new ArgumentException(
+ $"Parameter {nameof(targetLanguageCode)} must not be empty", nameof(targetLanguageCode));
+ }
+
+ return new GlossaryDictionaryRef(_manager, GlossaryId, sourceLanguageCode, targetLanguageCode);
+ }
+
+ /// Returns a fluent reference to a dictionary inside this glossary.
+ public GlossaryDictionaryRef Dictionary(MultilingualGlossaryDictionaryInfo glossaryDict) {
+ if (glossaryDict == null) throw new ArgumentNullException(nameof(glossaryDict));
+ return Dictionary(glossaryDict.SourceLanguageCode, glossaryDict.TargetLanguageCode);
+ }
+ }
+
+ /// Fluent reference for a single (source, target) dictionary inside a glossary.
+ public sealed class GlossaryDictionaryRef {
+ private readonly IGlossaryManager _manager;
+
+ internal GlossaryDictionaryRef(
+ IGlossaryManager manager,
+ string glossaryId,
+ string sourceLanguageCode,
+ string targetLanguageCode) {
+ _manager = manager;
+ GlossaryId = glossaryId;
+ SourceLanguageCode = sourceLanguageCode;
+ TargetLanguageCode = targetLanguageCode;
+ }
+
+ public string GlossaryId { get; }
+ public string SourceLanguageCode { get; }
+ public string TargetLanguageCode { get; }
+
+ /// Retrieves the dictionary entries.
+ public Task GetEntriesAsync(
+ CancellationToken cancellationToken = default) =>
+ _manager.GetMultilingualGlossaryDictionaryEntriesAsync(
+ GlossaryId,
+ SourceLanguageCode,
+ TargetLanguageCode,
+ cancellationToken);
+
+ /// Replaces the dictionary with the supplied entries (creates it if missing).
+ public Task ReplaceAsync(
+ GlossaryEntries entries,
+ CancellationToken cancellationToken = default) {
+ if (entries == null) throw new ArgumentNullException(nameof(entries));
+ return _manager.ReplaceMultilingualGlossaryDictionaryAsync(
+ GlossaryId,
+ SourceLanguageCode,
+ TargetLanguageCode,
+ entries,
+ cancellationToken);
+ }
+
+ /// Replaces the dictionary with CSV content (creates it if missing).
+ public Task ReplaceFromCsvAsync(
+ Stream csvFile,
+ CancellationToken cancellationToken = default) {
+ if (csvFile == null) throw new ArgumentNullException(nameof(csvFile));
+ return _manager.ReplaceMultilingualGlossaryDictionaryFromCsvAsync(
+ GlossaryId,
+ SourceLanguageCode,
+ TargetLanguageCode,
+ csvFile,
+ cancellationToken);
+ }
+
+ /// Merges the supplied entries into the existing dictionary (creates it if missing).
+ public Task MergeAsync(
+ GlossaryEntries entries,
+ CancellationToken cancellationToken = default) {
+ if (entries == null) throw new ArgumentNullException(nameof(entries));
+ return _manager.UpdateMultilingualGlossaryDictionaryAsync(
+ GlossaryId,
+ SourceLanguageCode,
+ TargetLanguageCode,
+ entries,
+ cancellationToken);
+ }
+
+ /// Merges the supplied CSV content into the existing dictionary.
+ public Task MergeFromCsvAsync(
+ Stream csvFile,
+ CancellationToken cancellationToken = default) {
+ if (csvFile == null) throw new ArgumentNullException(nameof(csvFile));
+ return _manager.UpdateMultilingualGlossaryDictionaryFromCsvAsync(
+ GlossaryId,
+ SourceLanguageCode,
+ TargetLanguageCode,
+ csvFile,
+ cancellationToken);
+ }
+
+ /// Deletes the dictionary from the glossary.
+ public Task DeleteAsync(CancellationToken cancellationToken = default) =>
+ _manager.DeleteMultilingualGlossaryDictionaryAsync(
+ GlossaryId,
+ SourceLanguageCode,
+ TargetLanguageCode,
+ cancellationToken);
+ }
+
+ ///
+ /// Fluent builder for creating a glossary with one or more dictionaries.
+ /// Call (or await directly) once dictionaries have been added.
+ ///
+ public sealed class GlossaryCreateBuilder {
+ private readonly IGlossaryManager _manager;
+ private readonly string _name;
+ private readonly List _dictionaries =
+ new List();
+ private Stream? _csvStream;
+ private string? _csvSourceLanguage;
+ private string? _csvTargetLanguage;
+ private CancellationToken _cancellationToken;
+
+ internal GlossaryCreateBuilder(IGlossaryManager manager, string name) {
+ _manager = manager;
+ _name = name;
+ }
+
+ /// Adds a dictionary to the glossary being created.
+ public GlossaryCreateBuilder WithDictionary(
+ string sourceLanguageCode,
+ string targetLanguageCode,
+ GlossaryEntries entries) {
+ if (string.IsNullOrWhiteSpace(sourceLanguageCode)) {
+ throw new ArgumentException(
+ $"Parameter {nameof(sourceLanguageCode)} must not be empty", nameof(sourceLanguageCode));
+ }
+
+ if (string.IsNullOrWhiteSpace(targetLanguageCode)) {
+ throw new ArgumentException(
+ $"Parameter {nameof(targetLanguageCode)} must not be empty", nameof(targetLanguageCode));
+ }
+
+ if (entries == null) throw new ArgumentNullException(nameof(entries));
+ EnsureNoCsv();
+ _dictionaries.Add(
+ new MultilingualGlossaryDictionaryEntries(sourceLanguageCode, targetLanguageCode, entries));
+ return this;
+ }
+
+ /// Adds a pre-built dictionary to the glossary being created.
+ public GlossaryCreateBuilder WithDictionary(MultilingualGlossaryDictionaryEntries dictionary) {
+ if (dictionary == null) throw new ArgumentNullException(nameof(dictionary));
+ EnsureNoCsv();
+ _dictionaries.Add(dictionary);
+ return this;
+ }
+
+ ///
+ /// Creates the glossary from a CSV stream. Mutually exclusive with ; the resulting
+ /// glossary will contain a single dictionary.
+ ///
+ public GlossaryCreateBuilder FromCsv(
+ string sourceLanguageCode,
+ string targetLanguageCode,
+ Stream csvFile) {
+ if (string.IsNullOrWhiteSpace(sourceLanguageCode)) {
+ throw new ArgumentException(
+ $"Parameter {nameof(sourceLanguageCode)} must not be empty", nameof(sourceLanguageCode));
+ }
+
+ if (string.IsNullOrWhiteSpace(targetLanguageCode)) {
+ throw new ArgumentException(
+ $"Parameter {nameof(targetLanguageCode)} must not be empty", nameof(targetLanguageCode));
+ }
+
+ if (csvFile == null) throw new ArgumentNullException(nameof(csvFile));
+ if (_dictionaries.Count > 0) {
+ throw new InvalidOperationException(
+ "FromCsv cannot be combined with WithDictionary. Pick one way of providing entries.");
+ }
+
+ _csvStream = csvFile;
+ _csvSourceLanguage = sourceLanguageCode;
+ _csvTargetLanguage = targetLanguageCode;
+ return this;
+ }
+
+ /// Associates a cancellation token with the create request.
+ public GlossaryCreateBuilder WithCancellation(CancellationToken cancellationToken) {
+ _cancellationToken = cancellationToken;
+ return this;
+ }
+
+ /// Executes the glossary creation request.
+ public Task CreateAsync() {
+ if (_csvStream != null) {
+ return _manager.CreateMultilingualGlossaryFromCsvAsync(
+ _name,
+ _csvSourceLanguage!,
+ _csvTargetLanguage!,
+ _csvStream,
+ _cancellationToken);
+ }
+
+ if (_dictionaries.Count == 0) {
+ throw new InvalidOperationException(
+ "At least one dictionary is required. Call WithDictionary(...) or FromCsv(...) before awaiting.");
+ }
+
+ return _manager.CreateMultilingualGlossaryAsync(_name, _dictionaries.ToArray(), _cancellationToken);
+ }
+
+ /// Enables direct await on the builder.
+ public TaskAwaiter GetAwaiter() => CreateAsync().GetAwaiter();
+
+ public static implicit operator Task(GlossaryCreateBuilder builder) =>
+ builder?.CreateAsync() ?? throw new ArgumentNullException(nameof(builder));
+
+ private void EnsureNoCsv() {
+ if (_csvStream != null) {
+ throw new InvalidOperationException(
+ "WithDictionary cannot be combined with FromCsv. Pick one way of providing entries.");
+ }
+ }
+ }
+}
diff --git a/DeepL/FluentStyleRule.cs b/DeepL/FluentStyleRule.cs
new file mode 100644
index 0000000..d53c9a6
--- /dev/null
+++ b/DeepL/FluentStyleRule.cs
@@ -0,0 +1,254 @@
+// Copyright 2026 DeepL SE (https://www.deepl.com)
+// Use of this source code is governed by an MIT
+// license that can be found in the LICENSE file.
+
+using System;
+using System.Collections.Generic;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using DeepL.Model;
+
+namespace DeepL {
+ ///
+ /// Fluent entry points for style-rule management on .
+ ///
+ ///
+ ///
+ /// var rule = await client
+ /// .CreateStyleRule("Marketing")
+ /// .ForLanguage("en")
+ /// .WithConfiguredRules(rules)
+ /// .WithInstruction("Friendly", "Be playful")
+ /// .WithInstruction("No jargon", "Avoid buzzwords")
+ /// .CreateAsync();
+ ///
+ /// var rules = await client.ListStyleRulesAsync();
+ ///
+ /// await client.StyleRule(id).RenameAsync("Marketing v2");
+ /// await client.StyleRule(id).AddInstructionAsync("label", "prompt");
+ /// await client.StyleRule(id).DeleteAsync();
+ ///
+ ///
+ public static class FluentStyleRuleExtensions {
+ /// Lists style rules (page / pageSize optional, detailed toggles full configured-rules payload).
+ public static Task ListStyleRulesAsync(
+ this IStyleRuleManager manager,
+ int? page = null,
+ int? pageSize = null,
+ bool? detailed = null,
+ CancellationToken cancellationToken = default) {
+ if (manager == null) throw new ArgumentNullException(nameof(manager));
+ return manager.GetAllStyleRulesAsync(page, pageSize, detailed, cancellationToken);
+ }
+
+ /// Returns a fluent reference to the style rule with the given ID.
+ public static StyleRuleRef StyleRule(this IStyleRuleManager manager, string styleId) {
+ if (manager == null) throw new ArgumentNullException(nameof(manager));
+ if (string.IsNullOrWhiteSpace(styleId)) {
+ throw new ArgumentException($"Parameter {nameof(styleId)} must not be empty", nameof(styleId));
+ }
+
+ return new StyleRuleRef(manager, styleId);
+ }
+
+ /// Returns a fluent reference for the supplied style rule.
+ public static StyleRuleRef StyleRule(this IStyleRuleManager manager, StyleRuleInfo styleRule) {
+ if (manager == null) throw new ArgumentNullException(nameof(manager));
+ if (styleRule == null) throw new ArgumentNullException(nameof(styleRule));
+ return new StyleRuleRef(manager, styleRule.StyleId);
+ }
+
+ /// Begins a fluent style-rule-creation builder.
+ public static StyleRuleCreateBuilder CreateStyleRule(this IStyleRuleManager manager, string name) {
+ if (manager == null) throw new ArgumentNullException(nameof(manager));
+ if (string.IsNullOrWhiteSpace(name)) {
+ throw new ArgumentException($"Parameter {nameof(name)} must not be empty", nameof(name));
+ }
+
+ return new StyleRuleCreateBuilder(manager, name);
+ }
+ }
+
+ /// Fluent reference for an existing style rule.
+ public sealed class StyleRuleRef {
+ private readonly IStyleRuleManager _manager;
+
+ internal StyleRuleRef(IStyleRuleManager manager, string styleId) {
+ _manager = manager;
+ StyleId = styleId;
+ }
+
+ public string StyleId { get; }
+
+ /// Retrieves the style rule.
+ public Task GetAsync(CancellationToken cancellationToken = default) =>
+ _manager.GetStyleRuleAsync(StyleId, cancellationToken);
+
+ /// Renames the style rule.
+ public Task RenameAsync(string name, CancellationToken cancellationToken = default) {
+ if (string.IsNullOrWhiteSpace(name)) {
+ throw new ArgumentException($"Parameter {nameof(name)} must not be empty", nameof(name));
+ }
+
+ return _manager.UpdateStyleRuleNameAsync(StyleId, name, cancellationToken);
+ }
+
+ /// Deletes the style rule.
+ public Task DeleteAsync(CancellationToken cancellationToken = default) =>
+ _manager.DeleteStyleRuleAsync(StyleId, cancellationToken);
+
+ /// Replaces the configured rules of this style rule.
+ public Task SetConfiguredRulesAsync(
+ ConfiguredRules configuredRules,
+ CancellationToken cancellationToken = default) {
+ if (configuredRules == null) throw new ArgumentNullException(nameof(configuredRules));
+ return _manager.UpdateStyleRuleConfiguredRulesAsync(StyleId, configuredRules, cancellationToken);
+ }
+
+ /// Adds a custom instruction to this style rule.
+ public Task AddInstructionAsync(
+ string label,
+ string prompt,
+ string? sourceLanguage = null,
+ CancellationToken cancellationToken = default) =>
+ _manager.CreateStyleRuleCustomInstructionAsync(StyleId, label, prompt, sourceLanguage, cancellationToken);
+
+ /// Returns a fluent reference to a custom instruction on this style rule.
+ public CustomInstructionRef Instruction(string instructionId) {
+ if (string.IsNullOrWhiteSpace(instructionId)) {
+ throw new ArgumentException(
+ $"Parameter {nameof(instructionId)} must not be empty", nameof(instructionId));
+ }
+
+ return new CustomInstructionRef(_manager, StyleId, instructionId);
+ }
+
+ /// Returns a fluent reference to a custom instruction on this style rule.
+ public CustomInstructionRef Instruction(CustomInstruction instruction) {
+ if (instruction == null) throw new ArgumentNullException(nameof(instruction));
+ if (string.IsNullOrEmpty(instruction.Id)) {
+ throw new ArgumentException(
+ "The supplied instruction has no ID (was it deserialized from a create response?)", nameof(instruction));
+ }
+
+ return new CustomInstructionRef(_manager, StyleId, instruction.Id!);
+ }
+ }
+
+ /// Fluent reference for a single custom instruction inside a style rule.
+ public sealed class CustomInstructionRef {
+ private readonly IStyleRuleManager _manager;
+
+ internal CustomInstructionRef(IStyleRuleManager manager, string styleId, string instructionId) {
+ _manager = manager;
+ StyleId = styleId;
+ InstructionId = instructionId;
+ }
+
+ public string StyleId { get; }
+ public string InstructionId { get; }
+
+ /// Retrieves the custom instruction.
+ public Task GetAsync(CancellationToken cancellationToken = default) =>
+ _manager.GetStyleRuleCustomInstructionAsync(StyleId, InstructionId, cancellationToken);
+
+ /// Replaces the custom instruction with the given label/prompt.
+ public Task UpdateAsync(
+ string label,
+ string prompt,
+ string? sourceLanguage = null,
+ CancellationToken cancellationToken = default) =>
+ _manager.UpdateStyleRuleCustomInstructionAsync(
+ StyleId,
+ InstructionId,
+ label,
+ prompt,
+ sourceLanguage,
+ cancellationToken);
+
+ /// Deletes the custom instruction.
+ public Task DeleteAsync(CancellationToken cancellationToken = default) =>
+ _manager.DeleteStyleRuleCustomInstructionAsync(StyleId, InstructionId, cancellationToken);
+ }
+
+ /// Fluent builder for creating a new style rule.
+ public sealed class StyleRuleCreateBuilder {
+ private readonly IStyleRuleManager _manager;
+ private readonly string _name;
+ private readonly List _instructions = new List();
+ private string? _language;
+ private ConfiguredRules? _configuredRules;
+ private CancellationToken _cancellationToken;
+
+ internal StyleRuleCreateBuilder(IStyleRuleManager manager, string name) {
+ _manager = manager;
+ _name = name;
+ }
+
+ /// Sets the language code for the style rule (required).
+ public StyleRuleCreateBuilder ForLanguage(string language) {
+ if (string.IsNullOrWhiteSpace(language)) {
+ throw new ArgumentException($"Parameter {nameof(language)} must not be empty", nameof(language));
+ }
+
+ _language = language;
+ return this;
+ }
+
+ /// Supplies configured rules for the style rule.
+ public StyleRuleCreateBuilder WithConfiguredRules(ConfiguredRules configuredRules) {
+ _configuredRules = configuredRules ?? throw new ArgumentNullException(nameof(configuredRules));
+ return this;
+ }
+
+ /// Adds a custom instruction to the style rule being created.
+ public StyleRuleCreateBuilder WithInstruction(
+ string label, string prompt, string? sourceLanguage = null) {
+ if (string.IsNullOrWhiteSpace(label)) {
+ throw new ArgumentException($"Parameter {nameof(label)} must not be empty", nameof(label));
+ }
+
+ if (string.IsNullOrWhiteSpace(prompt)) {
+ throw new ArgumentException($"Parameter {nameof(prompt)} must not be empty", nameof(prompt));
+ }
+
+ _instructions.Add(new CustomInstruction(label, prompt, sourceLanguage));
+ return this;
+ }
+
+ /// Adds a prepared custom instruction to the style rule being created.
+ public StyleRuleCreateBuilder WithInstruction(CustomInstruction instruction) {
+ if (instruction == null) throw new ArgumentNullException(nameof(instruction));
+ _instructions.Add(instruction);
+ return this;
+ }
+
+ /// Associates a cancellation token with the create request.
+ public StyleRuleCreateBuilder WithCancellation(CancellationToken cancellationToken) {
+ _cancellationToken = cancellationToken;
+ return this;
+ }
+
+ /// Executes the style-rule creation request.
+ public Task CreateAsync() {
+ if (_language == null) {
+ throw new InvalidOperationException(
+ "Language is required. Call .ForLanguage(languageCode) before awaiting.");
+ }
+
+ return _manager.CreateStyleRuleAsync(
+ _name,
+ _language,
+ _configuredRules,
+ _instructions.Count > 0 ? _instructions.ToArray() : null,
+ _cancellationToken);
+ }
+
+ /// Enables direct await on the builder.
+ public TaskAwaiter GetAwaiter() => CreateAsync().GetAwaiter();
+
+ public static implicit operator Task(StyleRuleCreateBuilder builder) =>
+ builder?.CreateAsync() ?? throw new ArgumentNullException(nameof(builder));
+ }
+}
diff --git a/DeepL/FluentTranslation.cs b/DeepL/FluentTranslation.cs
new file mode 100644
index 0000000..ef929aa
--- /dev/null
+++ b/DeepL/FluentTranslation.cs
@@ -0,0 +1,396 @@
+// Copyright 2026 DeepL SE (https://www.deepl.com)
+// Use of this source code is governed by an MIT
+// license that can be found in the LICENSE file.
+
+using System;
+using System.Collections.Generic;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using DeepL.Model;
+
+namespace DeepL {
+ ///
+ /// Fluent, LINQ-style entry points for the DeepL API. Builders returned from these
+ /// extensions are directly awaitable; executing them calls the underlying
+ /// / methods.
+ ///
+ ///
+ ///
+ /// TextResult result = await translator.Translate("Hello").From("en").To("de");
+ ///
+ /// TextResult styled = await translator
+ /// .Translate("Hello")
+ /// .To("de")
+ /// .WithFormality(Formality.More)
+ /// .WithStyle(styleRule)
+ /// .Using(opts => opts.CustomInstructions.Add("Keep it playful"));
+ ///
+ /// TextResult[] many = await translator.Translate(new[] { "a", "b" }).To("de");
+ ///
+ ///
+ public static class FluentTranslationExtensions {
+ /// Starts a fluent translation of a single text. Awaiting the returned builder yields a .
+ public static TextTranslationBuilder Translate(this ITranslator translator, string text) {
+ if (translator == null) throw new ArgumentNullException(nameof(translator));
+ if (text == null) throw new ArgumentNullException(nameof(text));
+ return new TextTranslationBuilder(translator, new[] { text });
+ }
+
+ /// Starts a fluent translation of multiple texts. Awaiting the returned builder yields a [].
+ public static TextTranslationBatchBuilder Translate(this ITranslator translator, IEnumerable texts) {
+ if (translator == null) throw new ArgumentNullException(nameof(translator));
+ if (texts == null) throw new ArgumentNullException(nameof(texts));
+ return new TextTranslationBatchBuilder(translator, texts);
+ }
+
+ /// Starts a fluent translation of multiple texts. Awaiting the returned builder yields a [].
+ public static TextTranslationBatchBuilder Translate(this ITranslator translator, params string[] texts) {
+ if (translator == null) throw new ArgumentNullException(nameof(translator));
+ if (texts == null) throw new ArgumentNullException(nameof(texts));
+ return new TextTranslationBatchBuilder(translator, texts);
+ }
+
+ /// Starts a fluent rephrase of a single text. Awaiting yields a .
+ public static TextRephraseBuilder Rephrase(this IWriter writer, string text) {
+ if (writer == null) throw new ArgumentNullException(nameof(writer));
+ if (text == null) throw new ArgumentNullException(nameof(text));
+ return new TextRephraseBuilder(writer, new[] { text });
+ }
+
+ /// Starts a fluent rephrase of multiple texts. Awaiting yields a [].
+ public static TextRephraseBatchBuilder Rephrase(this IWriter writer, IEnumerable texts) {
+ if (writer == null) throw new ArgumentNullException(nameof(writer));
+ if (texts == null) throw new ArgumentNullException(nameof(texts));
+ return new TextRephraseBatchBuilder(writer, texts);
+ }
+ }
+
+ ///
+ /// Common fluent configuration for text-translation builders.
+ /// Derived builders differ only in the shape of the awaited result.
+ ///
+ /// The concrete builder type, for fluent chaining.
+ public abstract class TextTranslationBuilderBase
+ where TSelf : TextTranslationBuilderBase {
+ internal readonly ITranslator Translator;
+ internal readonly IEnumerable Texts;
+ internal readonly TextTranslateOptions Options = new TextTranslateOptions();
+ internal string? SourceLanguageCode;
+ internal string? TargetLanguageCode;
+ internal CancellationToken CancellationToken;
+
+ internal TextTranslationBuilderBase(ITranslator translator, IEnumerable texts) {
+ Translator = translator;
+ Texts = texts;
+ }
+
+ private TSelf Self => (TSelf)this;
+
+ /// Sets the target language code.
+ public TSelf To(string targetLanguageCode) {
+ TargetLanguageCode = targetLanguageCode ?? throw new ArgumentNullException(nameof(targetLanguageCode));
+ return Self;
+ }
+
+ /// Sets the source language code. Pass null to rely on auto-detection.
+ public TSelf From(string? sourceLanguageCode) {
+ SourceLanguageCode = sourceLanguageCode;
+ return Self;
+ }
+
+ /// Copies fields from the supplied options onto this builder.
+ public TSelf Using(TextTranslateOptions options) {
+ if (options == null) throw new ArgumentNullException(nameof(options));
+ CopyOptions(options, Options);
+ return Self;
+ }
+
+ /// Mutates the builder's options via the supplied delegate.
+ public TSelf Using(Action configure) {
+ if (configure == null) throw new ArgumentNullException(nameof(configure));
+ configure(Options);
+ return Self;
+ }
+
+ /// Sets translation context (not counted toward billing).
+ public TSelf WithContext(string context) {
+ Options.Context = context;
+ return Self;
+ }
+
+ /// Sets the desired formality level.
+ public TSelf WithFormality(Formality formality) {
+ Options.Formality = formality;
+ return Self;
+ }
+
+ /// Uses the specified glossary.
+ public TSelf WithGlossary(GlossaryInfo glossary) {
+ if (glossary == null) throw new ArgumentNullException(nameof(glossary));
+ Options.GlossaryId = glossary.GlossaryId;
+ return Self;
+ }
+
+ /// Uses the specified multilingual glossary.
+ public TSelf WithGlossary(MultilingualGlossaryInfo glossary) {
+ if (glossary == null) throw new ArgumentNullException(nameof(glossary));
+ Options.GlossaryId = glossary.GlossaryId;
+ return Self;
+ }
+
+ /// Uses the glossary identified by .
+ public TSelf WithGlossaryId(string glossaryId) {
+ Options.GlossaryId = glossaryId ?? throw new ArgumentNullException(nameof(glossaryId));
+ return Self;
+ }
+
+ /// Uses the specified style rule.
+ public TSelf WithStyle(StyleRuleInfo styleRule) {
+ if (styleRule == null) throw new ArgumentNullException(nameof(styleRule));
+ Options.StyleId = styleRule.StyleId;
+ return Self;
+ }
+
+ /// Uses the style rule identified by .
+ public TSelf WithStyleId(string styleId) {
+ Options.StyleId = styleId ?? throw new ArgumentNullException(nameof(styleId));
+ return Self;
+ }
+
+ /// Selects the translation model to use.
+ public TSelf WithModel(ModelType modelType) {
+ Options.ModelType = modelType;
+ return Self;
+ }
+
+ /// Enables tag handling. Use "xml" or "html".
+ public TSelf WithTagHandling(string tagHandling, string? tagHandlingVersion = null) {
+ Options.TagHandling = tagHandling ?? throw new ArgumentNullException(nameof(tagHandling));
+ if (tagHandlingVersion != null) Options.TagHandlingVersion = tagHandlingVersion;
+ return Self;
+ }
+
+ /// Adds a single custom instruction to guide the translation.
+ public TSelf WithCustomInstruction(string instruction) {
+ if (instruction == null) throw new ArgumentNullException(nameof(instruction));
+ Options.CustomInstructions.Add(instruction);
+ return Self;
+ }
+
+ /// Adds one or more custom instructions to guide the translation.
+ public TSelf WithCustomInstructions(params string[] instructions) {
+ if (instructions == null) throw new ArgumentNullException(nameof(instructions));
+ foreach (var i in instructions) Options.CustomInstructions.Add(i);
+ return Self;
+ }
+
+ /// Disables automatic tag detection ( = false).
+ public TSelf WithoutOutlineDetection() {
+ Options.OutlineDetection = false;
+ return Self;
+ }
+
+ /// Preserves original formatting.
+ public TSelf PreserveFormatting() {
+ Options.PreserveFormatting = true;
+ return Self;
+ }
+
+ /// Sets the sentence splitting mode.
+ public TSelf WithSentenceSplitting(SentenceSplittingMode mode) {
+ Options.SentenceSplittingMode = mode;
+ return Self;
+ }
+
+ /// Associates a cancellation token with the eventual request.
+ public TSelf WithCancellation(CancellationToken cancellationToken) {
+ CancellationToken = cancellationToken;
+ return Self;
+ }
+
+ internal Task ExecuteAllAsync() {
+ if (TargetLanguageCode == null) {
+ throw new InvalidOperationException(
+ "Target language is required. Call .To(targetLanguageCode) before awaiting.");
+ }
+
+ return Translator.TranslateTextAsync(
+ Texts,
+ SourceLanguageCode,
+ TargetLanguageCode,
+ Options,
+ CancellationToken);
+ }
+
+ private static void CopyOptions(TextTranslateOptions src, TextTranslateOptions dst) {
+ dst.Context = src.Context;
+ dst.Formality = src.Formality;
+ dst.GlossaryId = src.GlossaryId;
+ dst.StyleId = src.StyleId;
+ dst.OutlineDetection = src.OutlineDetection;
+ dst.PreserveFormatting = src.PreserveFormatting;
+ dst.SentenceSplittingMode = src.SentenceSplittingMode;
+ dst.TagHandling = src.TagHandling;
+ dst.TagHandlingVersion = src.TagHandlingVersion;
+ dst.ModelType = src.ModelType;
+ ReplaceAll(dst.IgnoreTags, src.IgnoreTags);
+ ReplaceAll(dst.NonSplittingTags, src.NonSplittingTags);
+ ReplaceAll(dst.SplittingTags, src.SplittingTags);
+ ReplaceAll(dst.CustomInstructions, src.CustomInstructions);
+ }
+
+ private static void ReplaceAll(List dst, List src) {
+ dst.Clear();
+ foreach (var item in src) dst.Add(item);
+ }
+ }
+
+ ///
+ /// Fluent builder for translating a single text. await produces a .
+ ///
+ public sealed class TextTranslationBuilder : TextTranslationBuilderBase {
+ internal TextTranslationBuilder(ITranslator translator, IEnumerable texts)
+ : base(translator, texts) { }
+
+ /// Executes the translation and returns the single .
+ public async Task ExecuteAsync() => (await ExecuteAllAsync().ConfigureAwait(false))[0];
+
+ /// Enables direct await on the builder.
+ public TaskAwaiter GetAwaiter() => ExecuteAsync().GetAwaiter();
+
+ /// Implicit conversion so the builder may be passed where a is expected.
+ public static implicit operator Task(TextTranslationBuilder builder) =>
+ builder?.ExecuteAsync() ?? throw new ArgumentNullException(nameof(builder));
+ }
+
+ ///
+ /// Fluent builder for translating multiple texts. await produces a [].
+ ///
+ public sealed class TextTranslationBatchBuilder : TextTranslationBuilderBase {
+ internal TextTranslationBatchBuilder(ITranslator translator, IEnumerable texts)
+ : base(translator, texts) { }
+
+ /// Executes the translation and returns the array.
+ public Task ExecuteAsync() => ExecuteAllAsync();
+
+ /// Enables direct await on the builder.
+ public TaskAwaiter GetAwaiter() => ExecuteAsync().GetAwaiter();
+
+ /// Implicit conversion so the builder may be passed where a is expected.
+ public static implicit operator Task(TextTranslationBatchBuilder builder) =>
+ builder?.ExecuteAsync() ?? throw new ArgumentNullException(nameof(builder));
+ }
+
+ ///
+ /// Common fluent configuration for text-rephrase builders.
+ ///
+ /// The concrete builder type, for fluent chaining.
+ public abstract class TextRephraseBuilderBase
+ where TSelf : TextRephraseBuilderBase {
+ internal readonly IWriter Writer;
+ internal readonly IEnumerable Texts;
+ internal readonly TextRephraseOptions Options = new TextRephraseOptions();
+ internal string? TargetLanguageCode;
+ internal CancellationToken CancellationToken;
+
+ internal TextRephraseBuilderBase(IWriter writer, IEnumerable texts) {
+ Writer = writer;
+ Texts = texts;
+ }
+
+ private TSelf Self => (TSelf)this;
+
+ /// Sets the target language for the rephrasing. Pass null to rephrase in-language.
+ public TSelf To(string? targetLanguageCode) {
+ TargetLanguageCode = targetLanguageCode;
+ return Self;
+ }
+
+ ///
+ /// Sets the writing style. Mutually exclusive with ; throws
+ /// if a tone has already been set on this builder.
+ ///
+ public TSelf WithStyle(string writingStyle) {
+ if (writingStyle == null) throw new ArgumentNullException(nameof(writingStyle));
+ if (Options.WritingTone != null)
+ throw new InvalidOperationException(
+ "Cannot set WritingStyle when WritingTone is already set. Only one of WritingStyle or WritingTone may be specified per request.");
+ Options.WritingStyle = writingStyle;
+ return Self;
+ }
+
+ ///
+ /// Sets the writing tone. Mutually exclusive with ; throws
+ /// if a style has already been set on this builder.
+ ///
+ public TSelf WithTone(string writingTone) {
+ if (writingTone == null) throw new ArgumentNullException(nameof(writingTone));
+ if (Options.WritingStyle != null)
+ throw new InvalidOperationException(
+ "Cannot set WritingTone when WritingStyle is already set. Only one of WritingStyle or WritingTone may be specified per request.");
+ Options.WritingTone = writingTone;
+ return Self;
+ }
+
+ ///
+ /// Copies fields from the supplied options onto this builder. Throws
+ /// if the options have both
+ /// and
+ /// set simultaneously.
+ ///
+ public TSelf Using(TextRephraseOptions options) {
+ if (options == null) throw new ArgumentNullException(nameof(options));
+ if (options.WritingStyle != null && options.WritingTone != null)
+ throw new InvalidOperationException(
+ "Cannot copy options with both WritingStyle and WritingTone set. Only one of WritingStyle or WritingTone may be specified per request.");
+ Options.WritingStyle = options.WritingStyle;
+ Options.WritingTone = options.WritingTone;
+ return Self;
+ }
+
+ /// Mutates the options via the supplied delegate.
+ public TSelf Using(Action configure) {
+ if (configure == null) throw new ArgumentNullException(nameof(configure));
+ configure(Options);
+ return Self;
+ }
+
+ /// Associates a cancellation token with the eventual request.
+ public TSelf WithCancellation(CancellationToken cancellationToken) {
+ CancellationToken = cancellationToken;
+ return Self;
+ }
+
+ internal Task ExecuteAllAsync() =>
+ Writer.RephraseTextAsync(Texts, TargetLanguageCode, Options, CancellationToken);
+ }
+
+ /// Fluent builder for rephrasing a single text. await produces a .
+ public sealed class TextRephraseBuilder : TextRephraseBuilderBase {
+ internal TextRephraseBuilder(IWriter writer, IEnumerable texts) : base(writer, texts) { }
+
+ /// Executes the rephrase and returns the single .
+ public async Task ExecuteAsync() => (await ExecuteAllAsync().ConfigureAwait(false))[0];
+
+ /// Enables direct await.
+ public TaskAwaiter GetAwaiter() => ExecuteAsync().GetAwaiter();
+
+ public static implicit operator Task(TextRephraseBuilder builder) =>
+ builder?.ExecuteAsync() ?? throw new ArgumentNullException(nameof(builder));
+ }
+
+ /// Fluent builder for rephrasing multiple texts. await produces a [].
+ public sealed class TextRephraseBatchBuilder : TextRephraseBuilderBase {
+ internal TextRephraseBatchBuilder(IWriter writer, IEnumerable texts) : base(writer, texts) { }
+
+ /// Executes the rephrase and returns the array.
+ public Task ExecuteAsync() => ExecuteAllAsync();
+
+ /// Enables direct await.
+ public TaskAwaiter GetAwaiter() => ExecuteAsync().GetAwaiter();
+
+ public static implicit operator Task(TextRephraseBatchBuilder builder) =>
+ builder?.ExecuteAsync() ?? throw new ArgumentNullException(nameof(builder));
+ }
+}
diff --git a/DeepL/Internal/DeepLHttpClient.cs b/DeepL/Internal/DeepLHttpClient.cs
index 7f2282d..6256d1d 100644
--- a/DeepL/Internal/DeepLHttpClient.cs
+++ b/DeepL/Internal/DeepLHttpClient.cs
@@ -17,6 +17,9 @@
using Microsoft.Extensions.Http;
using Polly;
using Polly.Timeout;
+#if NET8_0_OR_GREATER
+using System.Net.Http.Json;
+#endif
namespace DeepL.Internal {
/// Identifies the type of resource being accessed, used for contextual error messages.
@@ -42,6 +45,40 @@ internal class DeepLHttpClient : IDisposable {
/// HTTP status code returned by DeepL API to indicate account translation quota has been exceeded.
private const HttpStatusCode HttpStatusCodeQuotaExceeded = (HttpStatusCode)456;
+ /// PATCH HTTP verb ( on net5+, fallback to string constructor on ns2.0).
+ private static readonly HttpMethod HttpMethodPatch =
+#if NET5_0_OR_GREATER
+ HttpMethod.Patch;
+#else
+ new HttpMethod("PATCH");
+#endif
+
+ ///
+ /// Creates a JSON-serialized request body. Uses on net8+ (streams directly,
+ /// skips the intermediate string allocation); falls back to on ns2.0.
+ ///
+ private static HttpContent CreateJsonContent(object body, JsonSerializerOptions? jsonOptions) {
+#if NET8_0_OR_GREATER
+ return JsonContent.Create(body, body?.GetType() ?? typeof(object), options: jsonOptions);
+#else
+ var jsonBody = JsonSerializer.Serialize(body, jsonOptions);
+ return new StringContent(jsonBody, Encoding.UTF8, "application/json");
+#endif
+ }
+
+ ///
+ /// Creates a form-URL-encoded request body. On net5+ uses the built-in
+ /// (the size-limit bug that originally required was fixed in .NET 5).
+ ///
+ private static HttpContent CreateFormContent(IEnumerable<(string Key, string Value)> bodyParams) {
+ var pairs = bodyParams.Select(pair => new KeyValuePair(pair.Key, pair.Value));
+#if NET5_0_OR_GREATER
+ return new FormUrlEncodedContent(pairs);
+#else
+ return new LargeFormUrlEncodedContent(pairs);
+#endif
+ }
+
/// true if should be disposed, otherwise false.
private readonly bool _disposeClient;
@@ -145,13 +182,33 @@ public static HttpClientAndDisposeFlag CreateDefaultHttpClient(
TimeSpan overallConnectionTimeout,
int maximumNetworkRetries) {
var handler = CreateHttpMessageHandlerWithRetryPolicy(
- new HttpClientHandler(),
+ CreateInnerHandler(),
perRetryConnectionTimeout,
maximumNetworkRetries);
+ var httpClient = new HttpClient(handler) { Timeout = overallConnectionTimeout };
+#if NET8_0_OR_GREATER
+ // Prefer HTTP/2 (the DeepL API supports it) and allow upgrade to HTTP/3 where available.
+ // Gives proper request multiplexing for high-throughput batch translation.
+ httpClient.DefaultRequestVersion = System.Net.HttpVersion.Version20;
+ httpClient.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrHigher;
+#endif
return new HttpClientAndDisposeFlag {
DisposeClient = true,
- HttpClient = new HttpClient(handler) { Timeout = overallConnectionTimeout }
+ HttpClient = httpClient
+ };
+ }
+
+ private static HttpMessageHandler CreateInnerHandler() {
+#if NET8_0_OR_GREATER
+ // SocketsHttpHandler is the modern managed handler; PooledConnectionLifetime forces periodic
+ // socket recreation so DNS changes are picked up on long-lived HttpClient instances.
+ return new SocketsHttpHandler {
+ PooledConnectionLifetime = TimeSpan.FromMinutes(5),
+ PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2),
};
+#else
+ return new HttpClientHandler();
+#endif
}
/// Checks the response HTTP status is OK, otherwise throws corresponding exception.
@@ -272,10 +329,7 @@ public async Task ApiPostAsync(
using var requestMessage = new HttpRequestMessage {
RequestUri = new Uri(_serverUrl, relativeUri),
Method = HttpMethod.Post,
- Content = bodyParams != null
- ? new LargeFormUrlEncodedContent(
- bodyParams.Select(pair => new KeyValuePair(pair.Key, pair.Value)))
- : null
+ Content = bodyParams != null ? CreateFormContent(bodyParams) : null
};
return await ApiCallAsync(requestMessage, cancellationToken);
}
@@ -292,11 +346,10 @@ public async Task ApiPostJsonAsync(
CancellationToken cancellationToken,
object body,
JsonSerializerOptions? jsonOptions = null) {
- var jsonBody = JsonSerializer.Serialize(body, jsonOptions);
using var requestMessage = new HttpRequestMessage {
RequestUri = new Uri(_serverUrl, relativeUri),
Method = HttpMethod.Post,
- Content = new StringContent(jsonBody, Encoding.UTF8, "application/json")
+ Content = CreateJsonContent(body, jsonOptions)
};
return await ApiCallAsync(requestMessage, cancellationToken);
}
@@ -314,10 +367,7 @@ public async Task ApiPutAsync(
using var requestMessage = new HttpRequestMessage {
RequestUri = new Uri(_serverUrl, relativeUri),
Method = HttpMethod.Put,
- Content = bodyParams != null
- ? new LargeFormUrlEncodedContent(
- bodyParams.Select(pair => new KeyValuePair(pair.Key, pair.Value)))
- : null
+ Content = bodyParams != null ? CreateFormContent(bodyParams) : null
};
return await ApiCallAsync(requestMessage, cancellationToken);
}
@@ -334,11 +384,10 @@ public async Task ApiPutJsonAsync(
CancellationToken cancellationToken,
object body,
JsonSerializerOptions? jsonOptions = null) {
- var jsonBody = JsonSerializer.Serialize(body, jsonOptions);
using var requestMessage = new HttpRequestMessage {
RequestUri = new Uri(_serverUrl, relativeUri),
Method = HttpMethod.Put,
- Content = new StringContent(jsonBody, Encoding.UTF8, "application/json")
+ Content = CreateJsonContent(body, jsonOptions)
};
return await ApiCallAsync(requestMessage, cancellationToken);
}
@@ -355,11 +404,8 @@ public async Task ApiPatchAsync(
IEnumerable<(string Key, string Value)>? bodyParams = null) {
using var requestMessage = new HttpRequestMessage {
RequestUri = new Uri(_serverUrl, relativeUri),
- Method = new HttpMethod("PATCH"),
- Content = bodyParams != null
- ? new LargeFormUrlEncodedContent(
- bodyParams.Select(pair => new KeyValuePair(pair.Key, pair.Value)))
- : null
+ Method = HttpMethodPatch,
+ Content = bodyParams != null ? CreateFormContent(bodyParams) : null
};
return await ApiCallAsync(requestMessage, cancellationToken);
}
@@ -376,11 +422,10 @@ public async Task ApiPatchJsonAsync(
CancellationToken cancellationToken,
object body,
JsonSerializerOptions? jsonOptions = null) {
- var jsonBody = JsonSerializer.Serialize(body, jsonOptions);
using var requestMessage = new HttpRequestMessage {
RequestUri = new Uri(_serverUrl, relativeUri),
- Method = new HttpMethod("PATCH"),
- Content = new StringContent(jsonBody, Encoding.UTF8, "application/json")
+ Method = HttpMethodPatch,
+ Content = CreateJsonContent(body, jsonOptions)
};
return await ApiCallAsync(requestMessage, cancellationToken);
}
diff --git a/DeepL/Internal/JsonUtils.cs b/DeepL/Internal/JsonUtils.cs
index 6fb07be..a4ecab2 100644
--- a/DeepL/Internal/JsonUtils.cs
+++ b/DeepL/Internal/JsonUtils.cs
@@ -3,58 +3,63 @@
// license that can be found in the LICENSE file.
using System.IO;
-using System.Linq;
using System.Net.Http;
using System.Text.Json;
+using System.Threading;
using System.Threading.Tasks;
+#if !NET8_0_OR_GREATER
+using System.Linq;
+#else
+using System.Net.Http.Json;
+#endif
namespace DeepL.Internal {
/// Internal class containing utility functions related to JSON-serialization.
internal static class JsonUtils {
/// Options used to deserialize JSON data.
- private static JsonSerializerOptions JsonSerializerOptions { get; } =
- new JsonSerializerOptions { PropertyNamingPolicy = LowerSnakeCaseNamingPolicy.Instance };
+ private static JsonSerializerOptions JsonSerializerOptions { get; } = new() {
+#if NET8_0_OR_GREATER
+ PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
+#else
+ PropertyNamingPolicy = LowerSnakeCaseNamingPolicy.Instance
+#endif
+ };
///
/// Deserializes JSON data in given HTTP response into a new object of type, with fields named in
/// lower-snake-case.
///
- /// containing HTTP response received from DeepL API.
- /// Type of deserialized object.
- /// Object of type initialized with values from JSON data.
- /// If the JSON data could not be deserialized correctly.
- internal static async Task DeserializeAsync(HttpResponseMessage responseMessage) =>
- await DeserializeAsync(await responseMessage.Content.ReadAsStreamAsync().ConfigureAwait(false))
- .ConfigureAwait(false);
+ internal static async Task DeserializeAsync(
+ HttpResponseMessage responseMessage,
+ CancellationToken cancellationToken = default) {
+#if NET8_0_OR_GREATER
+ var value = await responseMessage.Content
+ .ReadFromJsonAsync(JsonSerializerOptions, cancellationToken)
+ .ConfigureAwait(false);
+ return value ?? throw new DeepLException("Failed to deserialize JSON in received response");
+#else
+ using var stream = await responseMessage.Content.ReadAsStreamAsync().ConfigureAwait(false);
+ return await DeserializeAsync(stream).ConfigureAwait(false);
+#endif
+ }
- ///
- /// Deserializes JSON data in given stream into a new object of type, with fields named in
- /// lower-snake-case.
- ///
- /// Stream containing JSON data.
- /// Type of deserialized object.
- /// Object of type initialized with values from JSON data.
- /// If the JSON data could not be deserialized correctly.
+ /// Deserializes JSON data in given stream into a new object of type.
internal static async Task DeserializeAsync(Stream contentStream) {
- using var reader = new StreamReader(contentStream);
-
return await JsonSerializer.DeserializeAsync(contentStream, JsonSerializerOptions)
.ConfigureAwait(false) ??
throw new DeepLException("Failed to deserialize JSON in received response");
}
- /// JSON-field naming policy for lower-snake-case for example: "lower_snake_case".
+#if !NET8_0_OR_GREATER
+ /// JSON-field naming policy for lower-snake-case, e.g. "lower_snake_case". Used on netstandard2.0.
private sealed class LowerSnakeCaseNamingPolicy : JsonNamingPolicy {
- static LowerSnakeCaseNamingPolicy() {
- Instance = new LowerSnakeCaseNamingPolicy();
- }
-
- public static LowerSnakeCaseNamingPolicy Instance { get; }
+ public static LowerSnakeCaseNamingPolicy Instance { get; } = new();
public override string ConvertName(string name) =>
string
.Concat(name.Select((x, i) => i > 0 && char.IsUpper(x) ? "_" + x : x.ToString()))
.ToLowerInvariant();
}
+#endif
}
}
diff --git a/DeepL/Internal/LargeFormUrlEncodedContent.cs b/DeepL/Internal/LargeFormUrlEncodedContent.cs
index 6340f18..04ff8be 100644
--- a/DeepL/Internal/LargeFormUrlEncodedContent.cs
+++ b/DeepL/Internal/LargeFormUrlEncodedContent.cs
@@ -2,6 +2,7 @@
// Use of this source code is governed by an MIT
// license that can be found in the LICENSE file.
+#if !NET5_0_OR_GREATER
using System;
using System.Collections.Generic;
using System.Linq;
@@ -11,9 +12,12 @@
using System.Text;
namespace DeepL.Internal {
- /// Custom replacement for System.Net.Http.FormUrlEncodedContent to avoid size limitations.
- /// There was a bugfix for .NET 5 (https://github.com/dotnet/corefx/pull/41686) that solved this issue.
- /// This class avoids the problem by using WebUtility.UrlEncoded() instead of Uri.EscapeDataString().
+ ///
+ /// Custom replacement for on netstandard2.0 (and older .NET Framework)
+ /// to avoid the size limit in the pre-.NET 5 implementation.
+ /// See https://github.com/dotnet/corefx/pull/41686 — the fix shipped in .NET 5, so this type is compiled out for
+ /// modern targets and the built-in is used directly.
+ ///
public class LargeFormUrlEncodedContent : ByteArrayContent {
private static readonly Encoding Utf8Encoding = Encoding.UTF8;
@@ -34,3 +38,4 @@ private static byte[] GetContentByteArray(IEnumerable
- net5.0;netcoreapp3.1;net462
- 8
+ net8.0;net462
+ 12
true
enable
false
-
-
-
-
-
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
-
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
diff --git a/DeepLTests/FluentDocumentTranslationTest.cs b/DeepLTests/FluentDocumentTranslationTest.cs
new file mode 100644
index 0000000..6eb2b43
--- /dev/null
+++ b/DeepLTests/FluentDocumentTranslationTest.cs
@@ -0,0 +1,544 @@
+// Copyright 2026 DeepL SE (https://www.deepl.com)
+// Use of this source code is governed by an MIT
+// license that can be found in the LICENSE file.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using DeepL;
+using DeepL.Model;
+using NSubstitute;
+using Xunit;
+
+namespace DeepLTests {
+ ///
+ /// Unit tests for the fluent document-translation layer in FluentDocumentTranslation.cs.
+ /// Stream overloads are preferred where possible to avoid disk I/O.
+ ///
+ public sealed class FluentDocumentTranslationTest {
+ private static readonly DocumentHandle SampleHandle = new DocumentHandle("doc-id", "doc-key");
+
+ // ---------- SaveTo / one-shot translation ----------
+
+ [Fact]
+ public async Task StreamInput_SaveToStream_CallsStreamOverload() {
+ var translator = Substitute.For();
+ using var input = new MemoryStream(Encoding.UTF8.GetBytes("content"));
+ using var output = new MemoryStream();
+
+ await translator
+ .TranslateDocument(input, "input.docx")
+ .From("en")
+ .To("de")
+ .WithFormality(Formality.More)
+ .WithGlossaryId("glossary-x")
+ .SaveTo(output);
+
+ await translator.Received(1).TranslateDocumentAsync(
+ input,
+ "input.docx",
+ output,
+ "en",
+ "de",
+ Arg.Is(o =>
+ o != null && o.Formality == Formality.More && o.GlossaryId == "glossary-x"),
+ Arg.Any());
+ }
+
+ [Fact]
+ public async Task FileInput_SaveToFileInfo_CallsFileInfoOverload() {
+ var translator = Substitute.For();
+ var input = new FileInfo(Path.GetTempFileName());
+ var output = new FileInfo(Path.GetTempFileName() + ".out");
+ try {
+ File.WriteAllText(input.FullName, "hello");
+
+ await translator
+ .TranslateDocument(input)
+ .To("de")
+ .WithMinification()
+ .WithOutputFormat("pdf")
+ .SaveTo(output);
+
+ await translator.Received(1).TranslateDocumentAsync(
+ Arg.Is(f => f.FullName == input.FullName),
+ Arg.Is(f => f.FullName == output.FullName),
+ null,
+ "de",
+ Arg.Is(o =>
+ o != null && o.EnableDocumentMinification && o.OutputFormat == "pdf"),
+ Arg.Any());
+ } finally {
+ input.Refresh();
+ if (input.Exists) input.Delete();
+ }
+ }
+
+ [Fact]
+ public async Task UsingDelegate_MutatesOptions() {
+ var translator = Substitute.For();
+ using var input = new MemoryStream();
+ using var output = new MemoryStream();
+ DocumentTranslateOptions? captured = null;
+ translator.TranslateDocumentAsync(
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Do(o => captured = o),
+ Arg.Any())
+ .Returns(Task.CompletedTask);
+
+ await translator.TranslateDocument(input, "in.docx").To("de")
+ .Using(opts => {
+ opts.Formality = Formality.Less;
+ opts.OutputFormat = "txt";
+ })
+ .SaveTo(output);
+
+ Assert.NotNull(captured);
+ Assert.Equal(Formality.Less, captured!.Formality);
+ Assert.Equal("txt", captured.OutputFormat);
+ }
+
+ [Fact]
+ public async Task UsingOptionsObject_CopiesFields() {
+ var translator = Substitute.For();
+ using var input = new MemoryStream();
+ using var output = new MemoryStream();
+ DocumentTranslateOptions? captured = null;
+ translator.TranslateDocumentAsync(
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Do(o => captured = o),
+ Arg.Any())
+ .Returns(Task.CompletedTask);
+
+ var prepared = new DocumentTranslateOptions {
+ Formality = Formality.More,
+ GlossaryId = "glossary-id",
+ OutputFormat = "docx",
+ // EnableDocumentMinification is intentionally omitted: it requires FileInfo→FileInfo and
+ // is verified separately in FileInput_SaveToFileInfo_CallsFileInfoOverload.
+ };
+
+ await translator.TranslateDocument(input, "in.docx").To("de").Using(prepared).SaveTo(output);
+
+ Assert.Equal(Formality.More, captured!.Formality);
+ Assert.Equal("glossary-id", captured.GlossaryId);
+ Assert.Equal("docx", captured.OutputFormat);
+ }
+
+ [Fact]
+ public async Task WithCancellation_ExternalCancelPropagatesThroughLinkedToken() {
+ var translator = Substitute.For();
+ using var input = new MemoryStream();
+ using var output = new MemoryStream();
+ using var cts = new CancellationTokenSource();
+
+ // Hold the library call open until we cancel, so the linked CTS survives long enough to observe.
+ var tcs = new TaskCompletionSource();
+ CancellationToken capturedToken = default;
+ translator.TranslateDocumentAsync(
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Do(t => {
+ capturedToken = t;
+ t.Register(() => tcs.TrySetCanceled(t));
+ }))
+ .Returns(_ => tcs.Task);
+
+ var job = translator.TranslateDocument(input, "in.docx").To("de")
+ .WithCancellation(cts.Token).SaveTo(output);
+
+ // On net462 the Task continuation that invokes the library method (and runs our Arg.Do
+ // callback to capture the token) may not have landed yet. Wait briefly for the mock to
+ // record the token.
+ for (var i = 0; i < 50 && capturedToken == default; i++) {
+ await Task.Delay(10);
+ }
+ Assert.NotEqual(default, capturedToken);
+
+ // The token passed to the library is the LINKED token (not the user's raw cts.Token),
+ // but cancelling the original cts should propagate cancellation into it.
+ Assert.False(capturedToken.IsCancellationRequested);
+ cts.Cancel();
+ Assert.True(capturedToken.IsCancellationRequested);
+
+ await Assert.ThrowsAnyAsync(async () => await job);
+ }
+
+ // ---------- DocumentTranslationJob: Cancel() ----------
+
+ [Fact]
+ public async Task SaveTo_ReturnsAwaitableJob() {
+ var translator = Substitute.For();
+ using var input = new MemoryStream();
+ using var output = new MemoryStream();
+
+ // The returned value must be awaitable as a Task (implicit conversion) and as a job.
+ DocumentTranslationJob job = translator.TranslateDocument(input, "in.docx").To("de").SaveTo(output);
+ await job;
+ Assert.True(job.IsCompleted);
+
+ // Implicit conversion to Task also works (Task.WhenAll, etc.)
+ using var input2 = new MemoryStream();
+ using var output2 = new MemoryStream();
+ Task t = translator.TranslateDocument(input2, "in.docx").To("de").SaveTo(output2);
+ await t;
+ }
+
+ [Fact]
+ public async Task Job_Cancel_PropagatesThroughLinkedToken() {
+ var translator = Substitute.For();
+ using var input = new MemoryStream();
+ using var output = new MemoryStream();
+
+ // Capture the token the library receives, and make its Task never complete until cancelled.
+ var tcs = new TaskCompletionSource();
+ CancellationToken capturedToken = default;
+ translator.TranslateDocumentAsync(
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Do(t => {
+ capturedToken = t;
+ t.Register(() => tcs.TrySetCanceled(t));
+ }))
+ .Returns(_ => tcs.Task);
+
+ var job = translator.TranslateDocument(input, "in.docx").To("de").SaveTo(output);
+
+ Assert.False(job.IsCompleted);
+ job.Cancel();
+ Assert.True(capturedToken.IsCancellationRequested);
+
+ await Assert.ThrowsAnyAsync(async () => await job);
+ Assert.True(job.IsCompleted);
+ }
+
+ [Fact]
+ public async Task Job_Cancel_AfterCompletion_IsNoOp() {
+ var translator = Substitute.For();
+ using var input = new MemoryStream();
+ using var output = new MemoryStream();
+
+ var job = translator.TranslateDocument(input, "in.docx").To("de").SaveTo(output);
+ await job;
+
+ // Calling Cancel after completion must not throw (and must not affect anything).
+ job.Cancel();
+ job.Cancel();
+ Assert.True(job.IsCompleted);
+ }
+
+ // ---------- WithProgress: IProgress callbacks ----------
+
+ [Fact]
+ public async Task WithProgress_ReportsStatusDuringPolling() {
+ var translator = Substitute.For();
+ using var input = new MemoryStream();
+ using var output = new MemoryStream();
+ var handle = new DocumentHandle("doc-id", "doc-key");
+
+ // Return the handle from upload
+ translator.TranslateDocumentUploadAsync(
+ Arg.Any(), Arg.Any(),
+ Arg.Any(), Arg.Any(),
+ Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(handle));
+
+ // Sequence of status ticks: translating → translating → done
+ var statusQueue = new Queue(new[] {
+ new DocumentStatus("doc-id", DocumentStatus.StatusCode.Translating, 1, null, null),
+ new DocumentStatus("doc-id", DocumentStatus.StatusCode.Translating, 1, null, null),
+ new DocumentStatus("doc-id", DocumentStatus.StatusCode.Done, null, 42, null),
+ });
+ translator.TranslateDocumentStatusAsync(handle, Arg.Any())
+ .Returns(_ => statusQueue.Dequeue());
+
+ // Download does nothing; just return a completed Task.
+ translator.TranslateDocumentDownloadAsync(handle, Arg.Any(), Arg.Any())
+ .Returns(Task.CompletedTask);
+
+ var reported = new List();
+ var progress = new Progress(reported.Add);
+
+ await translator.TranslateDocument(input, "in.docx").To("de")
+ .WithProgress(progress)
+ .SaveTo(output);
+
+ // Progress should have been reported 3 times (matching the status sequence).
+ // Progress marshals to the captured sync context; give it a tick to flush.
+ for (var i = 0; i < 50 && reported.Count < 3; i++) {
+ await Task.Delay(10);
+ }
+
+ Assert.Equal(3, reported.Count);
+ Assert.Equal(DocumentStatus.StatusCode.Translating, reported[0].Status);
+ Assert.Equal(DocumentStatus.StatusCode.Done, reported[reported.Count - 1].Status);
+
+ // The upload + download must have been invoked exactly once each.
+ await translator.Received(1).TranslateDocumentUploadAsync(
+ Arg.Any(), Arg.Any(),
+ Arg.Any(), Arg.Any(),
+ Arg.Any(), Arg.Any());
+ await translator.Received(1).TranslateDocumentDownloadAsync(
+ handle, Arg.Any(), Arg.Any());
+ }
+
+ [Fact]
+ public async Task WithProgress_ErrorStatus_ThrowsDeepLException() {
+ var translator = Substitute.For();
+ using var input = new MemoryStream();
+ using var output = new MemoryStream();
+ var handle = new DocumentHandle("doc-id", "doc-key");
+
+ translator.TranslateDocumentUploadAsync(
+ Arg.Any(), Arg.Any(),
+ Arg.Any(), Arg.Any(),
+ Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(handle));
+ translator.TranslateDocumentStatusAsync(handle, Arg.Any())
+ .Returns(Task.FromResult(
+ new DocumentStatus("doc-id", DocumentStatus.StatusCode.Error, null, null, "something went wrong")));
+
+ var progress = new Progress(_ => { });
+
+ var ex = await Assert.ThrowsAsync(
+ async () => await translator.TranslateDocument(input, "in.docx").To("de")
+ .WithProgress(progress).SaveTo(output));
+ Assert.Contains("something went wrong", ex.Message);
+ }
+
+ [Fact]
+ public void WithProgress_NullProgress_Throws() {
+ var translator = Substitute.For();
+ using var input = new MemoryStream();
+ var builder = translator.TranslateDocument(input, "in.docx").To("de");
+ Assert.Throws(() => { _ = builder.WithProgress(null!); });
+ }
+
+ [Fact]
+ public async Task DocumentRef_WaitUntilDoneAsync_WithProgress_ReportsTicks() {
+ var translator = Substitute.For();
+ var handle = new DocumentHandle("doc-id", "doc-key");
+
+ var statusQueue = new Queue(new[] {
+ new DocumentStatus("doc-id", DocumentStatus.StatusCode.Translating, 1, null, null),
+ new DocumentStatus("doc-id", DocumentStatus.StatusCode.Done, null, 42, null),
+ });
+ translator.TranslateDocumentStatusAsync(handle, Arg.Any())
+ .Returns(_ => statusQueue.Dequeue());
+
+ var reported = new List();
+ var progress = new Progress(reported.Add);
+
+ await translator.Document(handle).WaitUntilDoneAsync(progress);
+
+ for (var i = 0; i < 50 && reported.Count < 2; i++) {
+ await Task.Delay(10);
+ }
+
+ Assert.Equal(2, reported.Count);
+ Assert.Equal(DocumentStatus.StatusCode.Done, reported[reported.Count - 1].Status);
+ }
+
+ // ---------- Upload-only / split flow ----------
+
+ [Fact]
+ public async Task UploadAsync_StreamInput_ReturnsHandle() {
+ var translator = Substitute.For();
+ using var input = new MemoryStream();
+ translator.TranslateDocumentUploadAsync(
+ input, "in.docx", Arg.Any(), "de",
+ Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(SampleHandle));
+
+ var handle = await translator.TranslateDocument(input, "in.docx").To("de").UploadAsync();
+
+ Assert.Equal("doc-id", handle.DocumentId);
+ }
+
+ [Fact]
+ public async Task UploadAsync_FileInput_CallsFileOverload() {
+ var translator = Substitute.For();
+ var input = new FileInfo(Path.GetTempFileName());
+ try {
+ File.WriteAllText(input.FullName, "content");
+ translator.TranslateDocumentUploadAsync(
+ Arg.Is(f => f.FullName == input.FullName),
+ Arg.Any(), "de",
+ Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(SampleHandle));
+
+ var handle = await translator.TranslateDocument(input).To("de").UploadAsync();
+
+ Assert.Equal("doc-id", handle.DocumentId);
+ } finally {
+ input.Refresh();
+ if (input.Exists) input.Delete();
+ }
+ }
+
+ // ---------- Validation ----------
+
+ [Fact]
+ public async Task MissingTarget_SaveTo_Throws() {
+ var translator = Substitute.For();
+ using var input = new MemoryStream();
+ using var output = new MemoryStream();
+
+ await Assert.ThrowsAsync(
+ async () => await translator.TranslateDocument(input, "in.docx").SaveTo(output));
+ }
+
+ [Fact]
+ public async Task MissingTarget_Upload_Throws() {
+ var translator = Substitute.For();
+ using var input = new MemoryStream();
+
+ await Assert.ThrowsAsync(
+ async () => await translator.TranslateDocument(input, "in.docx").UploadAsync());
+ }
+
+ [Fact]
+ public void TranslateDocument_NullInput_Throws() {
+ var translator = Substitute.For();
+ Assert.Throws(() => { _ = translator.TranslateDocument((FileInfo)null!); });
+ Assert.Throws(() => { _ = translator.TranslateDocument(null!, "in.docx"); });
+ Assert.Throws(() => { _ = translator.TranslateDocument(new MemoryStream(), ""); });
+ }
+
+ [Fact]
+ public void TranslateDocument_NullTranslator_Throws() {
+ ITranslator? translator = null;
+ Assert.Throws(
+ () => { _ = translator!.TranslateDocument(new MemoryStream(), "in.docx"); });
+ }
+
+ [Fact]
+ public void WithMinification_StreamInput_SaveToStream_Throws() {
+ var translator = Substitute.For();
+ using var input = new MemoryStream();
+ using var output = new MemoryStream();
+ Assert.Throws(() => {
+ translator.TranslateDocument(input, "in.docx").To("de").WithMinification().SaveTo(output).GetAwaiter()
+ .GetResult();
+ });
+ }
+
+ [Fact]
+ public void WithMinification_StreamInput_SaveToFileInfo_Throws() {
+ var translator = Substitute.For();
+ using var input = new MemoryStream();
+ var output = new FileInfo(Path.GetTempFileName() + ".out");
+ Assert.Throws(() => {
+ translator.TranslateDocument(input, "in.docx").To("de").WithMinification().SaveTo(output).GetAwaiter()
+ .GetResult();
+ });
+ }
+
+ [Fact]
+ public void WithMinification_FileInput_SaveToStream_Throws() {
+ var translator = Substitute.For();
+ var input = new FileInfo(Path.GetTempFileName());
+ try {
+ File.WriteAllText(input.FullName, "content");
+ using var output = new MemoryStream();
+ Assert.Throws(() => {
+ translator.TranslateDocument(input).To("de").WithMinification().SaveTo(output).GetAwaiter().GetResult();
+ });
+ } finally {
+ input.Refresh();
+ if (input.Exists) input.Delete();
+ }
+ }
+
+ [Fact]
+ public void WithMinification_WithProgress_Throws() {
+ var translator = Substitute.For();
+ var input = new FileInfo(Path.GetTempFileName());
+ try {
+ File.WriteAllText(input.FullName, "content");
+ var output = new FileInfo(Path.GetTempFileName() + ".out");
+ var progress = new Progress(_ => { });
+ Assert.Throws(() => {
+ translator.TranslateDocument(input).To("de").WithMinification().WithProgress(progress).SaveTo(output)
+ .GetAwaiter().GetResult();
+ });
+ } finally {
+ input.Refresh();
+ if (input.Exists) input.Delete();
+ }
+ }
+
+ // ---------- DocumentRef ----------
+
+ [Fact]
+ public async Task DocumentRef_GetStatusAsync_Forwards() {
+ var translator = Substitute.For