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(); + var status = new DocumentStatus("doc-id", DocumentStatus.StatusCode.Done, null, 42, null); + translator.TranslateDocumentStatusAsync(SampleHandle, Arg.Any()) + .Returns(Task.FromResult(status)); + + var result = await translator.Document(SampleHandle).GetStatusAsync(); + + Assert.Same(status, result); + } + + [Fact] + public async Task DocumentRef_WaitUntilDoneAsync_Forwards() { + var translator = Substitute.For(); + + await translator.Document(SampleHandle).WaitUntilDoneAsync(); + + await translator.Received(1).TranslateDocumentWaitUntilDoneAsync( + SampleHandle, Arg.Any()); + } + + [Fact] + public async Task DocumentRef_DownloadToAsync_StreamOverload() { + var translator = Substitute.For(); + using var output = new MemoryStream(); + + await translator.Document(SampleHandle).DownloadToAsync(output); + + await translator.Received(1).TranslateDocumentDownloadAsync( + SampleHandle, output, Arg.Any()); + } + + [Fact] + public async Task DocumentRef_DownloadToAsync_FileInfoOverload() { + var translator = Substitute.For(); + var output = new FileInfo(Path.GetTempFileName() + ".out"); + + await translator.Document(SampleHandle).DownloadToAsync(output); + + await translator.Received(1).TranslateDocumentDownloadAsync( + SampleHandle, + Arg.Is(f => f.FullName == output.FullName), + Arg.Any()); + } + + [Fact] + public void Document_NullTranslator_Throws() { + ITranslator? translator = null; + Assert.Throws(() => { _ = translator!.Document(SampleHandle); }); + } + } +} diff --git a/DeepLTests/FluentGlossaryTest.cs b/DeepLTests/FluentGlossaryTest.cs new file mode 100644 index 0000000..55c5929 --- /dev/null +++ b/DeepLTests/FluentGlossaryTest.cs @@ -0,0 +1,299 @@ +// 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.Text; +using System.Threading; +using System.Threading.Tasks; +using DeepL; +using DeepL.Model; +using NSubstitute; +using Xunit; + +namespace DeepLTests { + /// + /// Unit tests for the fluent glossary-management layer in FluentGlossary.cs. + /// Tests mock and verify argument forwarding. + /// + public sealed class FluentGlossaryTest { + private const string GlossaryId = "glossary-abc"; + + private static MultilingualGlossaryInfo MakeGlossaryInfo(string id = GlossaryId, string name = "test") => + new MultilingualGlossaryInfo(id, name, Array.Empty(), DateTime.UtcNow); + + private static MultilingualGlossaryDictionaryInfo MakeDictInfo( + string src = "en", string tgt = "de", int entries = 1) => + new MultilingualGlossaryDictionaryInfo(src, tgt, entries); + + private static MultilingualGlossaryDictionaryEntries MakeDictEntries(string src = "en", string tgt = "de") => + new MultilingualGlossaryDictionaryEntries( + src, tgt, new GlossaryEntries(new[] { ("hello", "hallo") })); + + private static GlossaryEntries MakeEntries() => + new GlossaryEntries(new[] { ("foo", "bar") }); + + // ---------- List ---------- + + [Fact] + public async Task ListGlossariesAsync_CallsUnderlying() { + var manager = Substitute.For(); + var expected = new[] { MakeGlossaryInfo() }; + manager.ListMultilingualGlossariesAsync(Arg.Any()) + .Returns(Task.FromResult(expected)); + + var result = await manager.ListGlossariesAsync(); + + Assert.Same(expected, result); + await manager.Received(1).ListMultilingualGlossariesAsync(Arg.Any()); + } + + // ---------- Glossary reference: Get / Rename / Delete ---------- + + [Fact] + public async Task GlossaryRef_GetAsync_CallsWithCorrectId() { + var manager = Substitute.For(); + var expected = MakeGlossaryInfo(); + manager.GetMultilingualGlossaryAsync(GlossaryId, Arg.Any()) + .Returns(Task.FromResult(expected)); + + var result = await manager.Glossary(GlossaryId).GetAsync(); + + Assert.Same(expected, result); + await manager.Received(1).GetMultilingualGlossaryAsync(GlossaryId, Arg.Any()); + } + + [Fact] + public async Task GlossaryRef_RenameAsync_CallsUpdateName() { + var manager = Substitute.For(); + var expected = MakeGlossaryInfo(name: "new name"); + manager.UpdateMultilingualGlossaryNameAsync(GlossaryId, "new name", Arg.Any()) + .Returns(Task.FromResult(expected)); + + var result = await manager.Glossary(GlossaryId).RenameAsync("new name"); + + Assert.Same(expected, result); + } + + [Fact] + public async Task GlossaryRef_DeleteAsync_CallsDelete() { + var manager = Substitute.For(); + + await manager.Glossary(GlossaryId).DeleteAsync(); + + await manager.Received(1).DeleteMultilingualGlossaryAsync(GlossaryId, Arg.Any()); + } + + [Fact] + public async Task GlossaryRef_FromInfo_UsesItsId() { + var manager = Substitute.For(); + var info = MakeGlossaryInfo("real-id"); + manager.GetMultilingualGlossaryAsync("real-id", Arg.Any()) + .Returns(Task.FromResult(info)); + + await manager.Glossary(info).GetAsync(); + + await manager.Received(1).GetMultilingualGlossaryAsync("real-id", Arg.Any()); + } + + // ---------- Dictionary reference ---------- + + [Fact] + public async Task DictionaryRef_GetEntriesAsync_Forwards() { + var manager = Substitute.For(); + var expected = MakeDictEntries(); + manager.GetMultilingualGlossaryDictionaryEntriesAsync(GlossaryId, "en", "de", Arg.Any()) + .Returns(Task.FromResult(expected)); + + var result = await manager.Glossary(GlossaryId).Dictionary("en", "de").GetEntriesAsync(); + + Assert.Same(expected, result); + } + + [Fact] + public async Task DictionaryRef_ReplaceAsync_ForwardsEntries() { + var manager = Substitute.For(); + var entries = MakeEntries(); + var expected = MakeDictInfo(); + manager.ReplaceMultilingualGlossaryDictionaryAsync( + GlossaryId, "en", "de", entries, Arg.Any()) + .Returns(Task.FromResult(expected)); + + var result = await manager.Glossary(GlossaryId).Dictionary("en", "de").ReplaceAsync(entries); + + Assert.Same(expected, result); + } + + [Fact] + public async Task DictionaryRef_MergeAsync_ForwardsToUpdate() { + var manager = Substitute.For(); + var entries = MakeEntries(); + var expected = MakeGlossaryInfo(); + manager.UpdateMultilingualGlossaryDictionaryAsync( + GlossaryId, "en", "de", entries, Arg.Any()) + .Returns(Task.FromResult(expected)); + + var result = await manager.Glossary(GlossaryId).Dictionary("en", "de").MergeAsync(entries); + + Assert.Same(expected, result); + } + + [Fact] + public async Task DictionaryRef_ReplaceFromCsvAsync_ForwardsStream() { + var manager = Substitute.For(); + using var stream = new MemoryStream(Encoding.UTF8.GetBytes("a,b")); + var expected = MakeDictInfo(); + manager.ReplaceMultilingualGlossaryDictionaryFromCsvAsync( + GlossaryId, "en", "de", stream, Arg.Any()) + .Returns(Task.FromResult(expected)); + + var result = await manager.Glossary(GlossaryId).Dictionary("en", "de").ReplaceFromCsvAsync(stream); + + Assert.Same(expected, result); + } + + [Fact] + public async Task DictionaryRef_DeleteAsync_Forwards() { + var manager = Substitute.For(); + + await manager.Glossary(GlossaryId).Dictionary("en", "de").DeleteAsync(); + + await manager.Received(1).DeleteMultilingualGlossaryDictionaryAsync( + GlossaryId, "en", "de", Arg.Any()); + } + + // ---------- Creation builder ---------- + + [Fact] + public async Task CreateGlossary_WithDictionaries_CallsCreateWithArray() { + var manager = Substitute.For(); + var expected = MakeGlossaryInfo(); + MultilingualGlossaryDictionaryEntries[]? captured = null; + manager.CreateMultilingualGlossaryAsync( + "My glossary", + Arg.Do(a => captured = a), + Arg.Any()) + .Returns(Task.FromResult(expected)); + + var dictA = MakeDictEntries("en", "de"); + var dictB = MakeDictEntries("de", "en"); + + var result = await manager.CreateGlossary("My glossary") + .WithDictionary(dictA) + .WithDictionary(dictB) + .CreateAsync(); + + Assert.Same(expected, result); + Assert.NotNull(captured); + Assert.Equal(2, captured!.Length); + Assert.Same(dictA, captured[0]); + Assert.Same(dictB, captured[1]); + } + + [Fact] + public async Task CreateGlossary_FromCsv_CallsCsvOverload() { + var manager = Substitute.For(); + var expected = MakeGlossaryInfo(); + using var stream = new MemoryStream(Encoding.UTF8.GetBytes("a,b")); + manager.CreateMultilingualGlossaryFromCsvAsync( + "csv glossary", "en", "de", stream, Arg.Any()) + .Returns(Task.FromResult(expected)); + + var result = await manager.CreateGlossary("csv glossary").FromCsv("en", "de", stream).CreateAsync(); + + Assert.Same(expected, result); + } + + [Fact] + public async Task CreateGlossary_ImplicitAwait_Works() { + var manager = Substitute.For(); + var expected = MakeGlossaryInfo(); + manager.CreateMultilingualGlossaryAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult(expected)); + + var result = await manager.CreateGlossary("My glossary") + .WithDictionary("en", "de", MakeEntries()); + + Assert.Same(expected, result); + } + + // ---------- Validation ---------- + + [Fact] + public async Task CreateGlossary_WithoutDictionaryOrCsv_Throws() { + var manager = Substitute.For(); + + await Assert.ThrowsAsync( + async () => await manager.CreateGlossary("empty").CreateAsync()); + } + + [Fact] + public void CreateGlossary_MixingCsvAndDictionary_Throws() { + var manager = Substitute.For(); + using var stream = new MemoryStream(); + + var builder = manager.CreateGlossary("x").FromCsv("en", "de", stream); + Assert.Throws(() => { _ = builder.WithDictionary("en", "de", MakeEntries()); }); + + var builder2 = manager.CreateGlossary("x").WithDictionary("en", "de", MakeEntries()); + Assert.Throws(() => { _ = builder2.FromCsv("en", "de", stream); }); + } + + [Fact] + public void Glossary_EmptyId_Throws() { + var manager = Substitute.For(); + Assert.Throws(() => { _ = manager.Glossary(""); }); + Assert.Throws(() => { _ = manager.Glossary(" "); }); + } + + [Fact] + public void CreateGlossary_EmptyName_Throws() { + var manager = Substitute.For(); + Assert.Throws(() => { _ = manager.CreateGlossary(""); }); + } + + [Fact] + public void Dictionary_EmptyLanguage_Throws() { + var manager = Substitute.For(); + var glossary = manager.Glossary(GlossaryId); + Assert.Throws(() => { _ = glossary.Dictionary("", "de"); }); + Assert.Throws(() => { _ = glossary.Dictionary("en", ""); }); + } + + [Fact] + public void WithDictionary_EmptySourceLanguage_Throws() { + var manager = Substitute.For(); + Assert.Throws(() => { _ = manager.CreateGlossary("g").WithDictionary("", "de", MakeEntries()); }); + Assert.Throws(() => { _ = manager.CreateGlossary("g").WithDictionary(" ", "de", MakeEntries()); }); + } + + [Fact] + public void WithDictionary_EmptyTargetLanguage_Throws() { + var manager = Substitute.For(); + Assert.Throws(() => { _ = manager.CreateGlossary("g").WithDictionary("en", "", MakeEntries()); }); + Assert.Throws(() => { _ = manager.CreateGlossary("g").WithDictionary("en", " ", MakeEntries()); }); + } + + [Fact] + public void FromCsv_EmptySourceLanguage_Throws() { + var manager = Substitute.For(); + using var stream = new MemoryStream(); + Assert.Throws(() => { _ = manager.CreateGlossary("g").FromCsv("", "de", stream); }); + Assert.Throws(() => { _ = manager.CreateGlossary("g").FromCsv(" ", "de", stream); }); + } + + [Fact] + public void FromCsv_EmptyTargetLanguage_Throws() { + var manager = Substitute.For(); + using var stream = new MemoryStream(); + Assert.Throws(() => { _ = manager.CreateGlossary("g").FromCsv("en", "", stream); }); + Assert.Throws(() => { _ = manager.CreateGlossary("g").FromCsv("en", " ", stream); }); + } + } +} diff --git a/DeepLTests/FluentStyleRuleTest.cs b/DeepLTests/FluentStyleRuleTest.cs new file mode 100644 index 0000000..bdcae89 --- /dev/null +++ b/DeepLTests/FluentStyleRuleTest.cs @@ -0,0 +1,247 @@ +// 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.Threading; +using System.Threading.Tasks; +using DeepL; +using DeepL.Model; +using NSubstitute; +using Xunit; + +namespace DeepLTests { + /// + /// Unit tests for the fluent style-rule-management layer in FluentStyleRule.cs. + /// + public sealed class FluentStyleRuleTest { + private const string StyleId = "style-abc"; + private const string InstructionId = "instr-123"; + + private static StyleRuleInfo MakeStyleRule(string id = StyleId, string name = "test") => + new StyleRuleInfo(id, name, DateTime.UtcNow, DateTime.UtcNow, "en", 1, null, null); + + private static CustomInstruction MakeInstruction(string id = InstructionId) => + new CustomInstruction("label", "prompt", "en", id); + + // ---------- List ---------- + + [Fact] + public async Task ListStyleRulesAsync_ForwardsPagingArgs() { + var manager = Substitute.For(); + var expected = new[] { MakeStyleRule() }; + manager.GetAllStyleRulesAsync(2, 50, true, Arg.Any()) + .Returns(Task.FromResult(expected)); + + var result = await manager.ListStyleRulesAsync(page: 2, pageSize: 50, detailed: true); + + Assert.Same(expected, result); + } + + // ---------- Ref: Get / Rename / Delete ---------- + + [Fact] + public async Task StyleRuleRef_GetAsync_CallsWithCorrectId() { + var manager = Substitute.For(); + var expected = MakeStyleRule(); + manager.GetStyleRuleAsync(StyleId, Arg.Any()) + .Returns(Task.FromResult(expected)); + + var result = await manager.StyleRule(StyleId).GetAsync(); + + Assert.Same(expected, result); + } + + [Fact] + public async Task StyleRuleRef_RenameAsync_CallsUpdateName() { + var manager = Substitute.For(); + var expected = MakeStyleRule(name: "new"); + manager.UpdateStyleRuleNameAsync(StyleId, "new", Arg.Any()) + .Returns(Task.FromResult(expected)); + + var result = await manager.StyleRule(StyleId).RenameAsync("new"); + + Assert.Same(expected, result); + } + + [Fact] + public async Task StyleRuleRef_DeleteAsync_Forwards() { + var manager = Substitute.For(); + + await manager.StyleRule(StyleId).DeleteAsync(); + + await manager.Received(1).DeleteStyleRuleAsync(StyleId, Arg.Any()); + } + + [Fact] + public async Task StyleRuleRef_SetConfiguredRulesAsync_Forwards() { + var manager = Substitute.For(); + var rules = new ConfiguredRules(); + var expected = MakeStyleRule(); + manager.UpdateStyleRuleConfiguredRulesAsync(StyleId, rules, Arg.Any()) + .Returns(Task.FromResult(expected)); + + var result = await manager.StyleRule(StyleId).SetConfiguredRulesAsync(rules); + + Assert.Same(expected, result); + } + + [Fact] + public async Task StyleRuleRef_AddInstructionAsync_Forwards() { + var manager = Substitute.For(); + var expected = MakeInstruction(); + manager.CreateStyleRuleCustomInstructionAsync( + StyleId, "label", "prompt", "en", Arg.Any()) + .Returns(Task.FromResult(expected)); + + var result = await manager.StyleRule(StyleId).AddInstructionAsync("label", "prompt", "en"); + + Assert.Same(expected, result); + } + + [Fact] + public async Task StyleRuleRef_FromInfo_UsesItsId() { + var manager = Substitute.For(); + var info = MakeStyleRule("real"); + manager.GetStyleRuleAsync("real", Arg.Any()) + .Returns(Task.FromResult(info)); + + await manager.StyleRule(info).GetAsync(); + + await manager.Received(1).GetStyleRuleAsync("real", Arg.Any()); + } + + // ---------- CustomInstructionRef ---------- + + [Fact] + public async Task InstructionRef_GetAsync_Forwards() { + var manager = Substitute.For(); + var expected = MakeInstruction(); + manager.GetStyleRuleCustomInstructionAsync(StyleId, InstructionId, Arg.Any()) + .Returns(Task.FromResult(expected)); + + var result = await manager.StyleRule(StyleId).Instruction(InstructionId).GetAsync(); + + Assert.Same(expected, result); + } + + [Fact] + public async Task InstructionRef_UpdateAsync_Forwards() { + var manager = Substitute.For(); + var expected = MakeInstruction(); + manager.UpdateStyleRuleCustomInstructionAsync( + StyleId, InstructionId, "new-label", "new-prompt", null, Arg.Any()) + .Returns(Task.FromResult(expected)); + + var result = await manager.StyleRule(StyleId).Instruction(InstructionId) + .UpdateAsync("new-label", "new-prompt"); + + Assert.Same(expected, result); + } + + [Fact] + public async Task InstructionRef_DeleteAsync_Forwards() { + var manager = Substitute.For(); + + await manager.StyleRule(StyleId).Instruction(InstructionId).DeleteAsync(); + + await manager.Received(1).DeleteStyleRuleCustomInstructionAsync( + StyleId, InstructionId, Arg.Any()); + } + + [Fact] + public void InstructionRef_FromInstance_RequiresId() { + var manager = Substitute.For(); + // An instruction with null ID (as returned before save) must be rejected + var instrWithoutId = new CustomInstruction("l", "p", null, null); + + var styleRef = manager.StyleRule(StyleId); + Assert.Throws(() => { _ = styleRef.Instruction(instrWithoutId); }); + } + + // ---------- Creation builder ---------- + + [Fact] + public async Task CreateStyleRule_Full_ForwardsAllFields() { + var manager = Substitute.For(); + var expected = MakeStyleRule(); + var rules = new ConfiguredRules(); + CustomInstruction[]? capturedInstructions = null; + manager.CreateStyleRuleAsync( + "Marketing", + "en", + rules, + Arg.Do(xs => capturedInstructions = xs), + Arg.Any()) + .Returns(Task.FromResult(expected)); + + var result = await manager.CreateStyleRule("Marketing") + .ForLanguage("en") + .WithConfiguredRules(rules) + .WithInstruction("Friendly", "Be playful") + .WithInstruction("Short", "Keep it brief", "en"); + + Assert.Same(expected, result); + Assert.NotNull(capturedInstructions); + Assert.Equal(2, capturedInstructions!.Length); + Assert.Equal("Friendly", capturedInstructions[0].Label); + Assert.Equal("Short", capturedInstructions[1].Label); + Assert.Equal("en", capturedInstructions[1].SourceLanguage); + } + + [Fact] + public async Task CreateStyleRule_NoInstructions_PassesNull() { + var manager = Substitute.For(); + var expected = MakeStyleRule(); + manager.CreateStyleRuleAsync( + "Marketing", "en", null, null, Arg.Any()) + .Returns(Task.FromResult(expected)); + + var result = await manager.CreateStyleRule("Marketing").ForLanguage("en"); + + Assert.Same(expected, result); + } + + [Fact] + public async Task CreateStyleRule_WithoutLanguage_Throws() { + var manager = Substitute.For(); + + await Assert.ThrowsAsync( + async () => await manager.CreateStyleRule("Marketing")); + } + + [Fact] + public void CreateStyleRule_EmptyName_Throws() { + var manager = Substitute.For(); + Assert.Throws(() => { _ = manager.CreateStyleRule(""); }); + } + + [Fact] + public void CreateStyleRule_EmptyLanguage_Throws() { + var manager = Substitute.For(); + var builder = manager.CreateStyleRule("x"); + Assert.Throws(() => { _ = builder.ForLanguage(""); }); + } + + [Fact] + public void CreateStyleRule_EmptyInstruction_Throws() { + var manager = Substitute.For(); + var builder = manager.CreateStyleRule("x"); + Assert.Throws(() => { _ = builder.WithInstruction("", "p"); }); + Assert.Throws(() => { _ = builder.WithInstruction("l", ""); }); + } + + [Fact] + public void StyleRule_EmptyId_Throws() { + var manager = Substitute.For(); + Assert.Throws(() => { _ = manager.StyleRule(""); }); + } + + [Fact] + public void Instruction_EmptyId_Throws() { + var manager = Substitute.For(); + var styleRef = manager.StyleRule(StyleId); + Assert.Throws(() => { _ = styleRef.Instruction(""); }); + } + } +} diff --git a/DeepLTests/FluentTranslationTest.cs b/DeepLTests/FluentTranslationTest.cs new file mode 100644 index 0000000..8f6255d --- /dev/null +++ b/DeepLTests/FluentTranslationTest.cs @@ -0,0 +1,406 @@ +// 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.Linq; +using System.Threading; +using System.Threading.Tasks; +using DeepL; +using DeepL.Model; +using NSubstitute; +using Xunit; + +namespace DeepLTests { + /// + /// Unit tests for the fluent translation / rephrase layer in FluentTranslation.cs. + /// Tests mock the underlying / to verify + /// that fluent configuration flows into the correct call arguments. + /// + public sealed class FluentTranslationTest { + private static TextResult MakeTextResult(string text = "Hallo") => + new TextResult(text, "en", text.Length, null); + + private static WriteResult MakeWriteResult(string text = "Better") => + new WriteResult(text, "en", "en"); + + private static ITranslator MakeTranslator(params TextResult[] results) { + var translator = Substitute.For(); + translator.TranslateTextAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult(results.Length > 0 ? results : new[] { MakeTextResult() })); + return translator; + } + + private static IWriter MakeWriter(params WriteResult[] results) { + var writer = Substitute.For(); + writer.RephraseTextAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult(results.Length > 0 ? results : new[] { MakeWriteResult() })); + return writer; + } + + // ---------- Text translation: happy paths ---------- + + [Fact] + public async Task SingleText_ToTarget_CallsUnderlyingWithSingletonAndReturnsFirstResult() { + var expected = MakeTextResult("Hallo"); + var translator = MakeTranslator(expected); + + var result = await translator.Translate("Hello").To("de"); + + Assert.Same(expected, result); + await translator.Received(1).TranslateTextAsync( + Arg.Is>(xs => xs.SequenceEqual(new[] { "Hello" })), + null, + "de", + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task SingleText_WithFrom_PassesSourceLanguage() { + var translator = MakeTranslator(); + + await translator.Translate("Hello").From("en").To("de"); + + await translator.Received(1).TranslateTextAsync( + Arg.Any>(), + "en", + "de", + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task BatchText_ReturnsArray() { + var r1 = MakeTextResult("a"); + var r2 = MakeTextResult("b"); + var translator = MakeTranslator(r1, r2); + + TextResult[] result = await translator.Translate(new[] { "x", "y" }).To("de"); + + Assert.Equal(2, result.Length); + Assert.Same(r1, result[0]); + Assert.Same(r2, result[1]); + await translator.Received(1).TranslateTextAsync( + Arg.Is>(xs => xs.SequenceEqual(new[] { "x", "y" })), + null, + "de", + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ParamsOverload_AcceptsVarargs() { + var translator = MakeTranslator(MakeTextResult("a"), MakeTextResult("b"), MakeTextResult("c")); + + TextResult[] result = await translator.Translate("x", "y", "z").To("de"); + + Assert.Equal(3, result.Length); + await translator.Received(1).TranslateTextAsync( + Arg.Is>(xs => xs.SequenceEqual(new[] { "x", "y", "z" })), + null, + "de", + Arg.Any(), + Arg.Any()); + } + + // ---------- Option configuration ---------- + + [Fact] + public async Task WithFormality_SetsOptionsFormality() { + var translator = MakeTranslator(); + TextTranslateOptions? captured = null; + translator.TranslateTextAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any(), + Arg.Do(o => captured = o), + Arg.Any()) + .Returns(Task.FromResult(new[] { MakeTextResult() })); + + await translator.Translate("Hi").To("de").WithFormality(Formality.More); + + Assert.NotNull(captured); + Assert.Equal(Formality.More, captured!.Formality); + } + + [Fact] + public async Task WithGlossaryId_And_WithStyleId_PropagateIds() { + var translator = MakeTranslator(); + TextTranslateOptions? captured = null; + translator.TranslateTextAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any(), + Arg.Do(o => captured = o), + Arg.Any()) + .Returns(Task.FromResult(new[] { MakeTextResult() })); + + await translator.Translate("Hi").To("de") + .WithGlossaryId("glossary-123") + .WithStyleId("style-456") + .WithModel(ModelType.QualityOptimized); + + Assert.Equal("glossary-123", captured!.GlossaryId); + Assert.Equal("style-456", captured.StyleId); + Assert.Equal(ModelType.QualityOptimized, captured.ModelType); + } + + [Fact] + public async Task WithCustomInstructions_AppendsToList() { + var translator = MakeTranslator(); + TextTranslateOptions? captured = null; + translator.TranslateTextAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any(), + Arg.Do(o => captured = o), + Arg.Any()) + .Returns(Task.FromResult(new[] { MakeTextResult() })); + + await translator.Translate("Hi").To("de") + .WithCustomInstruction("keep it short") + .WithCustomInstructions("no jargon", "playful"); + + Assert.Equal(new[] { "keep it short", "no jargon", "playful" }, captured!.CustomInstructions); + } + + [Fact] + public async Task UsingDelegate_MutatesOptions() { + var translator = MakeTranslator(); + TextTranslateOptions? captured = null; + translator.TranslateTextAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any(), + Arg.Do(o => captured = o), + Arg.Any()) + .Returns(Task.FromResult(new[] { MakeTextResult() })); + + await translator.Translate("Hi").To("de") + .Using(o => { + o.Context = "test context"; + o.PreserveFormatting = true; + o.IgnoreTags.Add("code"); + }); + + Assert.Equal("test context", captured!.Context); + Assert.True(captured.PreserveFormatting); + Assert.Contains("code", captured.IgnoreTags); + } + + [Fact] + public async Task UsingOptionsObject_CopiesFieldsOntoBuilder() { + var translator = MakeTranslator(); + TextTranslateOptions? captured = null; + translator.TranslateTextAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any(), + Arg.Do(o => captured = o), + Arg.Any()) + .Returns(Task.FromResult(new[] { MakeTextResult() })); + + var prepared = new TextTranslateOptions { + Formality = Formality.Less, + GlossaryId = "g", + Context = "ctx", + }; + prepared.IgnoreTags.Add("x"); + + await translator.Translate("Hi").To("de").Using(prepared); + + Assert.Equal(Formality.Less, captured!.Formality); + Assert.Equal("g", captured.GlossaryId); + Assert.Equal("ctx", captured.Context); + Assert.Contains("x", captured.IgnoreTags); + } + + [Fact] + public async Task WithCancellation_PassesToken() { + var translator = MakeTranslator(); + using var cts = new CancellationTokenSource(); + + await translator.Translate("Hi").To("de").WithCancellation(cts.Token); + + await translator.Received(1).TranslateTextAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + cts.Token); + } + + // ---------- Validation ---------- + + [Fact] + public async Task MissingTarget_ThrowsInvalidOperationException() { + var translator = MakeTranslator(); + + await Assert.ThrowsAsync( + async () => await translator.Translate("Hi")); + } + + [Fact] + public void Translate_NullText_Throws() { + var translator = MakeTranslator(); + Assert.Throws(() => { _ = translator.Translate((string)null!); }); + } + + [Fact] + public void Translate_NullEnumerable_Throws() { + var translator = MakeTranslator(); + Assert.Throws(() => { _ = translator.Translate((IEnumerable)null!); }); + } + + [Fact] + public void Translate_NullTranslator_Throws() { + ITranslator? translator = null; + Assert.Throws(() => { _ = translator!.Translate("Hi"); }); + } + + [Fact] + public void To_NullLanguage_Throws() { + var translator = MakeTranslator(); + Assert.Throws(() => { _ = translator.Translate("Hi").To(null!); }); + } + + // ---------- Task conversion ---------- + + [Fact] + public async Task ImplicitTaskConversion_Works() { + var translator = MakeTranslator(MakeTextResult("Hallo")); + + Task task = translator.Translate("Hello").To("de"); + var result = await task; + + Assert.Equal("Hallo", result.Text); + } + + [Fact] + public async Task BatchImplicitTaskConversion_Works() { + var translator = MakeTranslator(MakeTextResult("a"), MakeTextResult("b")); + + Task task = translator.Translate(new[] { "x", "y" }).To("de"); + var result = await task; + + Assert.Equal(2, result.Length); + } + + // ---------- Rephrase ---------- + + [Fact] + public async Task Rephrase_Single_CallsUnderlyingAndReturnsFirst() { + var expected = MakeWriteResult("Better"); + var writer = MakeWriter(expected); + + var result = await writer.Rephrase("Bad text").To("en-US").WithStyle("business"); + + Assert.Same(expected, result); + await writer.Received(1).RephraseTextAsync( + Arg.Is>(xs => xs.SequenceEqual(new[] { "Bad text" })), + "en-US", + Arg.Is(o => o != null && o.WritingStyle == "business"), + Arg.Any()); + } + + [Fact] + public async Task Rephrase_Batch_ReturnsArray() { + var writer = MakeWriter(MakeWriteResult("a"), MakeWriteResult("b")); + + WriteResult[] result = await writer.Rephrase(new[] { "x", "y" }).To("en").WithTone("friendly"); + + Assert.Equal(2, result.Length); + await writer.Received(1).RephraseTextAsync( + Arg.Is>(xs => xs.SequenceEqual(new[] { "x", "y" })), + "en", + Arg.Is(o => o != null && o.WritingTone == "friendly"), + Arg.Any()); + } + + [Fact] + public async Task Rephrase_UsingDelegate_Mutates() { + var writer = MakeWriter(); + TextRephraseOptions? captured = null; + writer.RephraseTextAsync( + Arg.Any>(), + Arg.Any(), + Arg.Do(o => captured = o), + Arg.Any()) + .Returns(Task.FromResult(new[] { MakeWriteResult() })); + + await writer.Rephrase("Bad").To(null).Using(o => { + o.WritingStyle = "academic"; + }); + + Assert.Equal("academic", captured!.WritingStyle); + } + + [Fact] + public void Rephrase_WithTone_AfterWithStyle_ThrowsInvalidOperation() { + var writer = MakeWriter(); + var builder = writer.Rephrase("text").WithStyle("academic"); + Assert.Throws(() => { builder.WithTone("friendly"); }); + } + + [Fact] + public void Rephrase_WithStyle_AfterWithTone_ThrowsInvalidOperation() { + var writer = MakeWriter(); + var builder = writer.Rephrase("text").WithTone("friendly"); + Assert.Throws(() => { builder.WithStyle("academic"); }); + } + + [Fact] + public void Rephrase_UsingOptions_BothSet_ThrowsInvalidOperation() { + var writer = MakeWriter(); + var opts = new TextRephraseOptions { WritingStyle = "academic", WritingTone = "friendly" }; + Assert.Throws(() => { writer.Rephrase("text").Using(opts); }); + } + + [Fact] + public async Task Rephrase_UsingOptions_OnlyStyle_Propagates() { + var writer = MakeWriter(); + TextRephraseOptions? captured = null; + writer.RephraseTextAsync( + Arg.Any>(), + Arg.Any(), + Arg.Do(o => captured = o), + Arg.Any()) + .Returns(Task.FromResult(new[] { MakeWriteResult() })); + + var opts = new TextRephraseOptions { WritingStyle = "business" }; + await writer.Rephrase("text").Using(opts); + + Assert.Equal("business", captured!.WritingStyle); + Assert.Null(captured.WritingTone); + } + + [Fact] + public async Task Rephrase_UsingOptions_OnlyTone_Propagates() { + var writer = MakeWriter(); + TextRephraseOptions? captured = null; + writer.RephraseTextAsync( + Arg.Any>(), + Arg.Any(), + Arg.Do(o => captured = o), + Arg.Any()) + .Returns(Task.FromResult(new[] { MakeWriteResult() })); + + var opts = new TextRephraseOptions { WritingTone = "casual" }; + await writer.Rephrase("text").Using(opts); + + Assert.Null(captured!.WritingStyle); + Assert.Equal("casual", captured.WritingTone); + } + } +} diff --git a/samples/DeepL.Samples.slnx b/samples/DeepL.Samples.slnx new file mode 100644 index 0000000..983d6ae --- /dev/null +++ b/samples/DeepL.Samples.slnx @@ -0,0 +1,5 @@ + + + + + diff --git a/samples/DependencyInjection/DependencyInjection.csproj b/samples/DependencyInjection/DependencyInjection.csproj new file mode 100644 index 0000000..0eb32de --- /dev/null +++ b/samples/DependencyInjection/DependencyInjection.csproj @@ -0,0 +1,14 @@ + + + + Exe + DeepL.Samples.DependencyInjection + DeepL.Samples.DependencyInjection + + + + + + + + diff --git a/samples/DependencyInjection/Program.cs b/samples/DependencyInjection/Program.cs new file mode 100644 index 0000000..22ee5b7 --- /dev/null +++ b/samples/DependencyInjection/Program.cs @@ -0,0 +1,41 @@ +// 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. + +// Demonstrates consuming DeepL.net via Microsoft.Extensions.DependencyInjection and the +// generic host. The setup pattern transfers directly to ASP.NET Core apps — swap +// Host.CreateApplicationBuilder for WebApplication.CreateBuilder and the service +// registration is identical. +// +// Uses the companion DeepL.Extensions.DependencyInjection package for AddDeepLClient. +// +// Run with: +// set DEEPL_AUTH_KEY=your-key-here +// dotnet run --project samples/DependencyInjection + +using DeepL.Extensions.DependencyInjection; +using DeepL.Samples.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +var builder = Host.CreateApplicationBuilder(args); + +// Option A: configure inline +builder.Services.AddDeepLClient(options => { + options.AuthKey = Environment.GetEnvironmentVariable("DEEPL_AUTH_KEY") + ?? throw new InvalidOperationException( + "Set DEEPL_AUTH_KEY to run this sample."); +}); + +// Option B (commented out): bind from appsettings.json with a "DeepL" section +// builder.Services.AddDeepLClient(builder.Configuration); + +// Register the consumer-side IHostedService that pulls DeepL interfaces out of DI. +builder.Services.AddHostedService(); + +var host = builder.Build(); + +// Drive the hosted service once, then shut down — this is a console sample, not a daemon. +// In a real long-lived app you'd call host.RunAsync() instead. +await host.StartAsync(); +await host.StopAsync(); diff --git a/samples/DependencyInjection/TranslationService.cs b/samples/DependencyInjection/TranslationService.cs new file mode 100644 index 0000000..e37bb1f --- /dev/null +++ b/samples/DependencyInjection/TranslationService.cs @@ -0,0 +1,48 @@ +// 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 DeepL; +using DeepL.Model; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace DeepL.Samples.DependencyInjection; + +/// +/// A sample hosted service that demonstrates consuming DeepL via constructor injection. +/// Real applications would inject only the interface(s) they actually use +/// (e.g. alone) rather than pulling in the full client. +/// +public sealed class TranslationService( + ITranslator translator, + IWriter writer, + IGlossaryManager glossaryManager, + ILogger logger) : IHostedService { + public async Task StartAsync(CancellationToken cancellationToken) { + logger.LogInformation("Demo: translating via injected ITranslator"); + + // The fluent extension methods are plain extensions over ITranslator / IWriter / etc., + // so they work the same with a DI-resolved instance as with a manually-constructed one. + var greeting = await translator + .Translate("Hello from dependency injection!") + .From(LanguageCode.English) + .To(LanguageCode.German) + .WithCancellation(cancellationToken); + + logger.LogInformation("Translated: {Text}", greeting.Text); + + var improved = await writer + .Rephrase("i maked an example of DI") + .To(LanguageCode.EnglishAmerican) + .WithTone("friendly") + .WithCancellation(cancellationToken); + + logger.LogInformation("Rephrased: {Text}", improved.Text); + + var glossaries = await glossaryManager.ListGlossariesAsync(cancellationToken); + logger.LogInformation("Account has {Count} glossary/ies", glossaries.Length); + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/samples/Directory.Build.props b/samples/Directory.Build.props new file mode 100644 index 0000000..d98259e --- /dev/null +++ b/samples/Directory.Build.props @@ -0,0 +1,17 @@ + + + + net8.0 + 12 + enable + enable + false + false + false + + diff --git a/samples/FluentApi/FluentApi.csproj b/samples/FluentApi/FluentApi.csproj new file mode 100644 index 0000000..6c4661f --- /dev/null +++ b/samples/FluentApi/FluentApi.csproj @@ -0,0 +1,13 @@ + + + + Exe + DeepL.Samples.FluentApi + DeepL.Samples.FluentApi + + + + + + + diff --git a/samples/FluentApi/Program.cs b/samples/FluentApi/Program.cs new file mode 100644 index 0000000..dfe9ed9 --- /dev/null +++ b/samples/FluentApi/Program.cs @@ -0,0 +1,300 @@ +// 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. + +// Demonstrates every fluent entry point the DeepL .NET client exposes: +// - text translation (single + batch + options) +// - text rephrasing +// - document translation (one-shot + split upload/poll/download) +// - glossary management (list / create / inspect / modify / delete) +// - style rule management (list / create / inspect / instructions / delete) +// +// Run with: +// set DEEPL_AUTH_KEY=your-key-here +// dotnet run --project samples/FluentApi +// +// Each sample is self-contained — comment out the ones you don't want to run. + +using DeepL; +using DeepL.Model; + +var authKey = Environment.GetEnvironmentVariable("DEEPL_AUTH_KEY") + ?? throw new InvalidOperationException( + "Set the DEEPL_AUTH_KEY environment variable to your DeepL API key."); + +using var client = new DeepLClient(authKey); + +await FluentTextExamples.RunAsync(client); +await FluentRephraseExamples.RunAsync(client); +await FluentDocumentExamples.RunAsync(client); +await FluentGlossaryExamples.RunAsync(client); +await FluentStyleRuleExamples.RunAsync(client); + +Console.WriteLine(); +Console.WriteLine("All samples completed."); + + +static class FluentTextExamples { + public static async Task RunAsync(DeepLClient client) { + Console.WriteLine("== Fluent text translation =="); + + // Simplest form: single text, target only (source auto-detected). + var simple = await client.Translate("Hello, world!").To(LanguageCode.German); + Console.WriteLine($" simple : {simple.Text} [detected: {simple.DetectedSourceLanguageCode}]"); + + // Explicit source, chain of option helpers. + var styled = await client + .Translate("Hello, team — quick reminder about tomorrow's meeting.") + .From(LanguageCode.English) + .To(LanguageCode.German) + .WithFormality(Formality.More) + .WithContext("Internal team chat message, friendly-but-professional tone.") + .WithCustomInstructions("Keep it concise", "Do not translate proper names"); + Console.WriteLine($" styled : {styled.Text}"); + + // Options-object overload — drop in a pre-built options instance. + var prepared = new TextTranslateOptions { + Formality = Formality.Less, + PreserveFormatting = true, + }; + var withOptions = await client.Translate("Hey!").To(LanguageCode.German).Using(prepared); + Console.WriteLine($" opts obj : {withOptions.Text}"); + + // Lambda overload — mutate options inline. + var withLambda = await client + .Translate("

Hello

") + .To(LanguageCode.German) + .Using(o => { + o.TagHandling = "html"; + o.IgnoreTags.Add("code"); + }); + Console.WriteLine($" lambda opts : {withLambda.Text}"); + + // Batch translation — returns TextResult[] + var batch = await client.Translate("Good morning", "How are you?", "See you soon").To(LanguageCode.German); + Console.WriteLine($" batch : [{string.Join(" | ", batch.Select(r => r.Text))}]"); + + // Enumerable input + cancellation token. + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var fromList = await client.Translate(new List { "Yes", "No" }) + .From("en").To("de") + .WithCancellation(cts.Token); + Console.WriteLine($" list+ct : [{string.Join(" | ", fromList.Select(r => r.Text))}]"); + + Console.WriteLine(); + } +} + + +static class FluentRephraseExamples { + public static async Task RunAsync(DeepLClient client) { + Console.WriteLine("== Fluent rephrase =="); + + var improved = await client + .Rephrase("This text has some grammar mistake and stuff like that.") + .To(LanguageCode.EnglishAmerican) + .WithTone("friendly"); + Console.WriteLine($" single : {improved.Text}"); + + var batch = await client + .Rephrase(new[] { "i go store", "He don't like it" }) + .To(LanguageCode.EnglishBritish) + .WithStyle("business"); + foreach (var r in batch) { + Console.WriteLine($" batch item : {r.Text}"); + } + + Console.WriteLine(); + } +} + + +static class FluentDocumentExamples { + public static async Task RunAsync(DeepLClient client) { + Console.WriteLine("== Fluent document translation =="); + + // Create a tiny source "document" on disk so the sample is self-contained. + var workDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "deepl-samples")); + var input = new FileInfo(Path.Combine(workDir.FullName, "hello.txt")); + var output = new FileInfo(Path.Combine(workDir.FullName, $"hello-{Guid.NewGuid():N}.txt")); + await File.WriteAllTextAsync(input.FullName, "Hello, world. This is a sample document."); + + try { + // One-shot: upload + poll + download, fluent options. + await client + .TranslateDocument(input) + .From(LanguageCode.English) + .To(LanguageCode.German) + .WithFormality(Formality.More) + .WithMinification() + .SaveTo(output); + + Console.WriteLine($" one-shot : wrote {output.Length} bytes → {output.FullName}"); + + // With progress callback: each status poll is reported via IProgress. + // Useful for UI progress bars, structured logging, webhook emissions. + var inputP = new FileInfo(Path.Combine(workDir.FullName, "hello-progress.txt")); + var outputP = new FileInfo(Path.Combine(workDir.FullName, $"hello-progress-{Guid.NewGuid():N}.txt")); + await File.WriteAllTextAsync(inputP.FullName, "Third doc — monitored via IProgress."); + + var progress = new Progress(status => + Console.WriteLine( + $" progress : {status.Status} (remaining: {status.SecondsRemaining?.ToString() ?? "n/a"})")); + + await client + .TranslateDocument(inputP) + .To(LanguageCode.German) + .WithProgress(progress) + .SaveTo(outputP); + Console.WriteLine($" progress/done: wrote {outputP.Length} bytes → {outputP.FullName}"); + + // Fluent cancellation: SaveTo() returns a DocumentTranslationJob that supports .Cancel() + // without needing a pre-built CancellationTokenSource. The job is still awaitable. + var inputC = new FileInfo(Path.Combine(workDir.FullName, "hello-cancel.txt")); + var outputC = new FileInfo(Path.Combine(workDir.FullName, $"hello-cancel-{Guid.NewGuid():N}.txt")); + await File.WriteAllTextAsync(inputC.FullName, "Fourth doc — will be cancelled mid-flight."); + + var job = client.TranslateDocument(inputC).To(LanguageCode.German).SaveTo(outputC); + _ = Task.Delay(TimeSpan.FromMilliseconds(150)).ContinueWith(_ => { + Console.WriteLine(" cancel : requesting cancellation..."); + job.Cancel(); + }); + try { + await job; + Console.WriteLine(" cancel : finished before cancel fired (race; may happen on small docs)"); + } catch (OperationCanceledException) { + Console.WriteLine(" cancel : job cancelled cleanly"); + } + + // Split flow: useful when you want to do work between upload and download + // (e.g. queue a webhook, show a progress UI). + var input2 = new FileInfo(Path.Combine(workDir.FullName, "hello2.txt")); + var output2 = new FileInfo(Path.Combine(workDir.FullName, $"hello2-{Guid.NewGuid():N}.txt")); + await File.WriteAllTextAsync(input2.FullName, "A second, independent document."); + + var handle = await client.TranslateDocument(input2).To(LanguageCode.German).UploadAsync(); + Console.WriteLine($" split/upload : {handle.DocumentId}"); + + // Poll status manually if you want progress output. + var status = await client.Document(handle).GetStatusAsync(); + Console.WriteLine($" split/status : {status.Status} (remaining: {status.SecondsRemaining?.ToString() ?? "n/a"})"); + + // Or block until done (with optional progress reporter). + await client.Document(handle).WaitUntilDoneAsync(progress); + await client.Document(handle).DownloadToAsync(output2); + Console.WriteLine($" split/done : wrote {output2.Length} bytes → {output2.FullName}"); + } finally { + try { input.Delete(); } catch { /* ignored */ } + } + + Console.WriteLine(); + } +} + + +static class FluentGlossaryExamples { + public static async Task RunAsync(DeepLClient client) { + Console.WriteLine("== Fluent glossary management =="); + + // List existing glossaries. + var existing = await client.ListGlossariesAsync(); + Console.WriteLine($" existing : {existing.Length} glossary/ies on account"); + + // Create a new glossary with two dictionaries (EN->DE and DE->EN). + var enDe = new GlossaryEntries(new[] { + ("hello", "hallo"), + ("team", "Mannschaft"), + }); + var deEn = new GlossaryEntries(new[] { + ("hallo", "hello"), + ("Mannschaft", "team"), + }); + + var glossaryName = $"sample-{Guid.NewGuid():N}"; + var created = await client + .CreateGlossary(glossaryName) + .WithDictionary("en", "de", enDe) + .WithDictionary("de", "en", deEn); + Console.WriteLine($" created : {created.Name} ({created.GlossaryId})"); + + try { + // Inspect the freshly created glossary. + var info = await client.Glossary(created.GlossaryId).GetAsync(); + Console.WriteLine($" inspected : {info.Dictionaries.Length} dict(s)"); + + // Pull the entries for a specific dictionary. + var entries = await client.Glossary(created.GlossaryId).Dictionary("en", "de").GetEntriesAsync(); + Console.WriteLine($" entries : {entries.Entries.ToDictionary().Count} pair(s) in EN→DE"); + + // Merge additional entries into an existing dictionary. + var moreEntries = new GlossaryEntries(new[] { ("goodbye", "auf Wiedersehen") }); + await client.Glossary(created.GlossaryId).Dictionary("en", "de").MergeAsync(moreEntries); + Console.WriteLine(" merged : added 'goodbye' → 'auf Wiedersehen'"); + + // Use the glossary in a translation (fluent WithGlossary). + var translated = await client + .Translate("Hello team, goodbye team!") + .From("en").To("de") + .WithGlossary(created); + Console.WriteLine($" applied : {translated.Text}"); + + // Rename. + await client.Glossary(created.GlossaryId).RenameAsync(glossaryName + "-v2"); + Console.WriteLine(" renamed : appended -v2"); + } finally { + // Always clean up sample resources. + await client.Glossary(created.GlossaryId).DeleteAsync(); + Console.WriteLine(" deleted : sample glossary removed"); + } + + Console.WriteLine(); + } +} + + +static class FluentStyleRuleExamples { + public static async Task RunAsync(DeepLClient client) { + Console.WriteLine("== Fluent style-rule management =="); + + var existing = await client.ListStyleRulesAsync(detailed: false); + Console.WriteLine($" existing : {existing.Length} style rule(s) on account"); + + var ruleName = $"sample-style-{Guid.NewGuid():N}"; + var rule = await client + .CreateStyleRule(ruleName) + .ForLanguage("en") + .WithInstruction("Friendly", "Write in a warm, friendly voice.") + .WithInstruction("No jargon", "Avoid technical buzzwords."); + Console.WriteLine($" created : {rule.Name} ({rule.StyleId})"); + + try { + // Add another instruction after creation. + var added = await client.StyleRule(rule.StyleId) + .AddInstructionAsync("Short", "Keep responses under 50 words."); + Console.WriteLine($" added instr : {added.Label} ({added.Id})"); + + // Update the instruction. + if (added.Id is { } instrId) { + await client.StyleRule(rule.StyleId).Instruction(instrId) + .UpdateAsync("Short-and-snappy", "One sentence or less."); + Console.WriteLine(" updated : renamed + reworded instruction"); + } + + // Apply the style rule in a translation. + var translated = await client + .Translate("We are pleased to announce the imminent deployment of our newest SaaS offering.") + .From("en").To("en-US") + .WithStyle(rule); + Console.WriteLine($" applied : {translated.Text}"); + + // Rename. + await client.StyleRule(rule.StyleId).RenameAsync(ruleName + "-v2"); + Console.WriteLine(" renamed : appended -v2"); + } finally { + await client.StyleRule(rule.StyleId).DeleteAsync(); + Console.WriteLine(" deleted : sample style rule removed"); + } + + Console.WriteLine(); + } +} diff --git a/samples/README.md b/samples/README.md new file mode 100644 index 0000000..c63e9dd --- /dev/null +++ b/samples/README.md @@ -0,0 +1,83 @@ +# DeepL.net samples + +Runnable samples that demonstrate how to use DeepL.net in .NET 8+ apps. + +The samples are in their own solution (`DeepL.Samples.slnx`) so they don't affect the main library CI scope or the NuGet package. They reference the library by **project reference** — `dotnet build` against the sibling source — so any changes you make to the library are picked up automatically. + +## Prerequisites + +- .NET 8 SDK (or newer) +- A DeepL API auth key — free or pro. Set it in the environment before running: + + ```bash + # bash / zsh + export DEEPL_AUTH_KEY=your-key-here + + # PowerShell + $env:DEEPL_AUTH_KEY = "your-key-here" + + # cmd + set DEEPL_AUTH_KEY=your-key-here + ``` + +## Samples + +### 1. `FluentApi` — every fluent entry point + +End-to-end console demo of the fluent API surface: + +- text translation (single, batch, params, `IEnumerable`) with every option helper +- text rephrasing (style + tone) +- document translation — both one-shot `SaveTo` and the split upload / poll / download flow +- glossary management (create → inspect → merge → rename → delete, plus using a glossary in translation) +- style rule management (create with instructions → add/update instruction → rename → delete) + +```bash +dotnet run --project samples/FluentApi +``` + +The sample creates temporary glossaries, style rules, and files, and cleans them all up in `finally` blocks. If a run is interrupted, any leftover `sample-*` glossaries on your account can be safely deleted manually. + +### 2. `DependencyInjection` — idiomatic DI wire-up + +Shows how to register `DeepLClient` into `Microsoft.Extensions.DependencyInjection` so consumers can inject the narrowest interface they need (`ITranslator`, `IWriter`, `IGlossaryManager`, `IStyleRuleManager`): + +- `AddDeepLClient(options => ...)` / `AddDeepLClient(IConfiguration)` — from the `DeepL.Extensions.DependencyInjection` companion package +- Routes the underlying `HttpClient` through `IHttpClientFactory` so apps can layer on their own handlers / resilience / logging +- Registers the client as a singleton (it is thread-safe by design) and exposes every surface interface +- `TranslationService` — example `IHostedService` that pulls `ITranslator` / `IWriter` / `IGlossaryManager` out of DI and uses the same fluent extensions + +```bash +dotnet run --project samples/DependencyInjection +``` + +This sample depends on the companion package `DeepL.Extensions.DependencyInjection`, which lives in its own project in this repo and ships as a separate NuGet package. It keeps the main `DeepL.net` package dependency-free for consumers who don't need DI. + +### Adapting to ASP.NET Core + +The DI sample uses the generic host (`Host.CreateApplicationBuilder`), but the `AddDeepLClient` registration is identical in an ASP.NET Core app: + +```csharp +using DeepL; +using DeepL.Extensions.DependencyInjection; + +var builder = WebApplication.CreateBuilder(args); + +// Bind from the "DeepL" configuration section +builder.Services.AddDeepLClient(builder.Configuration); + +var app = builder.Build(); + +app.MapPost("/translate", async (ITranslator translator, string text, string target) + => await translator.Translate(text).To(target)); + +app.Run(); +``` + +## Building only the samples + +```bash +dotnet build samples/DeepL.Samples.slnx +``` + +This also builds the library as a transitive dependency. To build the library alone, use the top-level `DeepL.net.sln`.