Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<LangVersion>12</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<WarningsAsErrors>nullable</WarningsAsErrors>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>DeepL.Extensions.DependencyInjection.Tests</RootNamespace>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<ProjectReference Include="..\DeepL.Extensions.DependencyInjection\DeepL.Extensions.DependencyInjection.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -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 {
/// <summary>
/// Tests for <see cref="DeepLServiceCollectionExtensions" />.
/// These are pure DI-container tests — no DeepL API is called, the configured auth key
/// is only used to construct the <see cref="DeepLClient" /> (construction is lazy-safe).
/// </summary>
public sealed class DeepLServiceCollectionExtensionsTest {
private const string FakeKey = "00000000-0000-0000-0000-000000000000:fx";

private static ServiceProvider BuildProvider(Action<IServiceCollection> 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<DeepLClient>();

Assert.NotNull(client);
}

[Fact]
public void AddDeepLClient_RegistersAllSurfaceInterfaces() {
using var sp = BuildProvider(s => s.AddDeepLClient(o => o.AuthKey = FakeKey));

Assert.NotNull(sp.GetService<ITranslator>());
Assert.NotNull(sp.GetService<IWriter>());
Assert.NotNull(sp.GetService<IGlossaryManager>());
Assert.NotNull(sp.GetService<IStyleRuleManager>());
}

[Fact]
public void AddDeepLClient_AllInterfacesResolveToSameSingleton() {
using var sp = BuildProvider(s => s.AddDeepLClient(o => o.AuthKey = FakeKey));

var client = sp.GetRequiredService<DeepLClient>();
var translator = sp.GetRequiredService<ITranslator>();
var writer = sp.GetRequiredService<IWriter>();
var glossary = sp.GetRequiredService<IGlossaryManager>();
var styleRule = sp.GetRequiredService<IStyleRuleManager>();

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<DeepLClient>();
var second = sp.GetRequiredService<DeepLClient>();

Assert.Same(first, second);
}

[Fact]
public void AddDeepLClient_MissingAuthKey_ThrowsOnResolve() {
using var sp = BuildProvider(s => s.AddDeepLClient(o => o.AuthKey = ""));

var ex = Assert.Throws<OptionsValidationException>(() => sp.GetRequiredService<DeepLClient>());
Assert.Contains("AuthKey", ex.Message);
}

[Fact]
public void AddDeepLClient_WhitespaceAuthKey_ThrowsOnResolve() {
using var sp = BuildProvider(s => s.AddDeepLClient(o => o.AuthKey = " "));

Assert.Throws<OptionsValidationException>(() => sp.GetRequiredService<DeepLClient>());
}

[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<IHttpClientFactory>();
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<DeepLClient>();
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<DeepLClient>();

// 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<DeepLClient>();

Assert.Single(clients);
}

[Fact]
public void AddDeepLClient_NullServices_Throws() {
IServiceCollection? services = null;
Assert.Throws<ArgumentNullException>(
() => services!.AddDeepLClient(o => o.AuthKey = FakeKey));
}

[Fact]
public void AddDeepLClient_NullConfigureDelegate_Throws() {
var services = new ServiceCollection();
Assert.Throws<ArgumentNullException>(
() => services.AddDeepLClient((Action<DeepLOptions>)null!));
}

// ---------- Configuration overload ----------

[Fact]
public void AddDeepLClient_ConfigurationOverload_BindsFromDefaultSection() {
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> {
["DeepL:AuthKey"] = FakeKey,
["DeepL:ServerUrl"] = "https://api.deepl.com/"
})
.Build();

using var sp = BuildProvider(s => s.AddDeepLClient(config));

var opts = sp.GetRequiredService<IOptions<DeepLOptions>>().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<string, string?> {
["Translation:DeepL:AuthKey"] = FakeKey,
})
.Build();

using var sp = BuildProvider(s => s.AddDeepLClient(config.GetSection("Translation:DeepL")));

var opts = sp.GetRequiredService<IOptions<DeepLOptions>>().Value;
Assert.Equal(FakeKey, opts.AuthKey);
}

[Fact]
public void AddDeepLClient_ConfigurationOverload_MissingKey_Throws() {
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>())
.Build();

using var sp = BuildProvider(s => s.AddDeepLClient(config));

Assert.Throws<OptionsValidationException>(() => sp.GetRequiredService<DeepLClient>());
}

[Fact]
public void AddDeepLClient_ConfigurationOverload_NullConfig_Throws() {
var services = new ServiceCollection();
Assert.Throws<ArgumentNullException>(
() => services.AddDeepLClient((IConfiguration)null!));
}

// ---------- Test helpers ----------

private sealed class UriCapturingHandler : HttpMessageHandler {
public Uri? LastRequestUri { get; private set; }

protected override System.Threading.Tasks.Task<HttpResponseMessage> 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("{}") });
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<Description>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.</Description>
Comment thread
DeeJayTC marked this conversation as resolved.
<AssemblyTitle>DeepL.Extensions.DependencyInjection</AssemblyTitle>
<Version>1.20.0</Version>
<PackageVersion>1.20.0</PackageVersion>
<FileVersion>1.20.0.0</FileVersion>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<TargetFrameworks>netstandard2.0;net8.0</TargetFrameworks>
<LangVersion>12</LangVersion>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<WarningsAsErrors>nullable</WarningsAsErrors>
<RootNamespace>DeepL.Extensions.DependencyInjection</RootNamespace>
<Authors>DeepL SE</Authors>
<Company>DeepL SE</Company>
<AssemblyName>DeepL.Extensions.DependencyInjection</AssemblyName>
<PackageId>DeepL.Extensions.DependencyInjection</PackageId>
<PackageTags>deepl;translation;api;dependency-injection;di;aspnetcore</PackageTags>
<PackageIcon>icon.png</PackageIcon>
<PackageProjectUrl>https://www.deepl.com/pro-api</PackageProjectUrl>
<RepositoryUrl>https://github.com/DeepLcom/deepl-dotnet</RepositoryUrl>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageReleaseNotes>Release notes can be found at https://github.com/DeepLcom/deepl-dotnet/blob/main/CHANGELOG.md</PackageReleaseNotes>
<IsPackable>true</IsPackable>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<SignAssembly>true</SignAssembly>
<AssemblyOriginatorKeyFile>..\DeepL\sgKey.snk</AssemblyOriginatorKeyFile>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\DeepL\DeepL.csproj" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
</ItemGroup>

<ItemGroup>
<None Include="README.md" Pack="true" PackagePath=""/>
<None Include="..\icon.png" Pack="true" PackagePath=""/>
</ItemGroup>

</Project>
40 changes: 40 additions & 0 deletions DeepL.Extensions.DependencyInjection/DeepLOptions.cs
Original file line number Diff line number Diff line change
@@ -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 {
/// <summary>
/// Configuration contract for
/// <see cref="DeepLServiceCollectionExtensions.AddDeepLClient(Microsoft.Extensions.DependencyInjection.IServiceCollection,System.Action{DeepLOptions})" />
/// (and its overloads).
/// Typically populated from configuration:
/// <code>
/// services.AddDeepLClient(builder.Configuration.GetSection("DeepL"));
/// </code>
/// with a matching <c>appsettings.json</c> section:
/// <code>
/// "DeepL": {
/// "AuthKey": "...",
/// "ServerUrl": "https://api.deepl.com"
/// }
/// </code>
/// </summary>
public sealed class DeepLOptions {
/// <summary>Default configuration section name (<c>"DeepL"</c>) used by the <c>IConfiguration</c> overload.</summary>
public const string DefaultSectionName = "DeepL";

/// <summary>
/// Name used when resolving the underlying <see cref="System.Net.Http.HttpClient" /> via
/// <see cref="System.Net.Http.IHttpClientFactory" />. Consumers can call
/// <see cref="Microsoft.Extensions.DependencyInjection.HttpClientBuilderExtensions" />
/// against this name to layer on additional handlers or policies.
/// </summary>
public const string HttpClientName = "DeepL";

/// <summary>DeepL API auth key. Required.</summary>
public string AuthKey { get; set; } = string.Empty;

/// <summary>Optional override for the DeepL API server URL (for testing / proxying).</summary>
public string? ServerUrl { get; set; }
}
}
Loading