Skip to content
Draft
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
48 changes: 38 additions & 10 deletions DeepL/Internal/DeepLHttpClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,19 +54,29 @@ internal class DeepLHttpClient : IDisposable {
/// <summary>The base URL for DeepL's API.</summary>
private readonly Uri _serverUrl;

/// <summary>Whether to preserve the path component of the server URL when resolving API request URIs.</summary>
private readonly bool _preserveServerUrlPath;

/// <summary>Initializes a new <see cref="DeepLHttpClient" />.</summary>
/// <param name="serverUrl">Base server URL to apply to all relative URLs in requests.</param>
/// <param name="clientFactory">Factory function to obtain <see cref="HttpClient" /> used for requests.</param>
/// <param name="headers">HTTP headers applied to all requests.</param>
/// <param name="preserveServerUrlPath">
/// When <c>true</c>, the path component of <paramref name="serverUrl" /> is preserved in request URIs.
/// When <c>false</c>, API paths are resolved from the server root, ignoring any base path.
/// </param>
/// <exception cref="ArgumentNullException">If any argument is null.</exception>
internal DeepLHttpClient(
Uri serverUrl,
Func<HttpClientAndDisposeFlag> clientFactory,
IEnumerable<KeyValuePair<string, string?>> headers) {
IEnumerable<KeyValuePair<string, string?>> headers,
bool preserveServerUrlPath = true) {
if (serverUrl == null) {
throw new ArgumentNullException($"{nameof(serverUrl)}");
}

_preserveServerUrlPath = preserveServerUrlPath;

// Ensure the server URL ends with a trailing slash so that relative URI resolution
// (RFC 3986 §5.2.2) appends path segments rather than replacing the last segment.
// This is important when ServerUrl contains a path prefix such as a reverse-proxy base path.
Expand All @@ -85,6 +95,24 @@ internal DeepLHttpClient(
_headers = headers.ToArray();
}

/// <summary>
/// Resolves a relative URI against the server URL.
/// When <see cref="_preserveServerUrlPath" /> is <c>true</c>, the path component of the server URL is preserved.
/// When <c>false</c>, a leading slash is prepended to the relative URI so that it is resolved from the server root.
/// </summary>
/// <param name="relativeUri">Relative URI to resolve against the server URL.</param>
/// <returns>Resolved absolute <see cref="Uri" />.</returns>
private Uri ResolveUri(string relativeUri) {
if (_preserveServerUrlPath) {
return new Uri(_serverUrl, relativeUri);
}

// Prepend "/" so that RFC 3986 resolution treats it as an absolute path,
// effectively ignoring any base path in _serverUrl.
var absolutePath = relativeUri.StartsWith("/") ? relativeUri : $"/{relativeUri}";
return new Uri(_serverUrl, absolutePath);
}

/// <summary>
/// Releases the unmanaged resources and disposes of the managed resources used by the
/// <see cref="DeepLHttpClient" />.
Expand Down Expand Up @@ -230,7 +258,7 @@ public async Task<HttpResponseMessage> ApiGetAsync(
queryParams.Select(pair => $"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}"));

using var requestMessage = new HttpRequestMessage {
RequestUri = new Uri(_serverUrl, relativeUri + queryString),
RequestUri = ResolveUri(relativeUri + queryString),
Method = HttpMethod.Get,
Headers = { Accept = { new MediaTypeWithQualityHeaderValue(acceptHeader ?? "application/json") } }
};
Expand All @@ -253,7 +281,7 @@ public async Task<HttpResponseMessage> ApiDeleteAsync(
"&",
queryParams.Select(pair => $"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}"));
using var requestMessage = new HttpRequestMessage {
RequestUri = new Uri(_serverUrl, relativeUri + queryString),
RequestUri = ResolveUri(relativeUri + queryString),
Method = HttpMethod.Delete
};
return await ApiCallAsync(requestMessage, cancellationToken);
Expand All @@ -270,7 +298,7 @@ public async Task<HttpResponseMessage> ApiPostAsync(
CancellationToken cancellationToken,
IEnumerable<(string Key, string Value)>? bodyParams = null) {
using var requestMessage = new HttpRequestMessage {
RequestUri = new Uri(_serverUrl, relativeUri),
RequestUri = ResolveUri(relativeUri),
Method = HttpMethod.Post,
Content = bodyParams != null
? new LargeFormUrlEncodedContent(
Expand All @@ -294,7 +322,7 @@ public async Task<HttpResponseMessage> ApiPostJsonAsync(
JsonSerializerOptions? jsonOptions = null) {
var jsonBody = JsonSerializer.Serialize(body, jsonOptions);
using var requestMessage = new HttpRequestMessage {
RequestUri = new Uri(_serverUrl, relativeUri),
RequestUri = ResolveUri(relativeUri),
Method = HttpMethod.Post,
Content = new StringContent(jsonBody, Encoding.UTF8, "application/json")
};
Expand All @@ -312,7 +340,7 @@ public async Task<HttpResponseMessage> ApiPutAsync(
CancellationToken cancellationToken,
IEnumerable<(string Key, string Value)>? bodyParams = null) {
using var requestMessage = new HttpRequestMessage {
RequestUri = new Uri(_serverUrl, relativeUri),
RequestUri = ResolveUri(relativeUri),
Method = HttpMethod.Put,
Content = bodyParams != null
? new LargeFormUrlEncodedContent(
Expand All @@ -336,7 +364,7 @@ public async Task<HttpResponseMessage> ApiPutJsonAsync(
JsonSerializerOptions? jsonOptions = null) {
var jsonBody = JsonSerializer.Serialize(body, jsonOptions);
using var requestMessage = new HttpRequestMessage {
RequestUri = new Uri(_serverUrl, relativeUri),
RequestUri = ResolveUri(relativeUri),
Method = HttpMethod.Put,
Content = new StringContent(jsonBody, Encoding.UTF8, "application/json")
};
Expand All @@ -354,7 +382,7 @@ public async Task<HttpResponseMessage> ApiPatchAsync(
CancellationToken cancellationToken,
IEnumerable<(string Key, string Value)>? bodyParams = null) {
using var requestMessage = new HttpRequestMessage {
RequestUri = new Uri(_serverUrl, relativeUri),
RequestUri = ResolveUri(relativeUri),
Method = new HttpMethod("PATCH"),
Content = bodyParams != null
? new LargeFormUrlEncodedContent(
Expand All @@ -378,7 +406,7 @@ public async Task<HttpResponseMessage> ApiPatchJsonAsync(
JsonSerializerOptions? jsonOptions = null) {
var jsonBody = JsonSerializer.Serialize(body, jsonOptions);
using var requestMessage = new HttpRequestMessage {
RequestUri = new Uri(_serverUrl, relativeUri),
RequestUri = ResolveUri(relativeUri),
Method = new HttpMethod("PATCH"),
Content = new StringContent(jsonBody, Encoding.UTF8, "application/json")
};
Expand Down Expand Up @@ -407,7 +435,7 @@ public async Task<HttpResponseMessage> ApiUploadAsync(
content.Add(new StreamContent(file), "file", fileName);

using var requestMessage = new HttpRequestMessage {
RequestUri = new Uri(_serverUrl, relativeUri),
RequestUri = ResolveUri(relativeUri),
Method = HttpMethod.Post,
Content = content,
Headers = { Accept = { new MediaTypeWithQualityHeaderValue("application/json") } }
Expand Down
3 changes: 2 additions & 1 deletion DeepL/Translator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -462,7 +462,8 @@ public Translator(string authKey, TranslatorOptions? options = null) {
_client = new DeepLHttpClient(
serverUrl,
clientFactory,
headers);
headers,
options.PreserveServerUrlPath);
}

/// <summary>Releases the unmanaged resources and disposes of the managed resources used by the <see cref="Translator" />.</summary>
Expand Down
8 changes: 8 additions & 0 deletions DeepL/TranslatorOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ public class TranslatorOptions {
/// </summary>
public string? ServerUrl { get; set; }

/// <summary>
/// Controls whether the path component of <see cref="ServerUrl" /> is preserved when constructing API request URLs.
/// Set to <c>true</c> (default) to preserve the base path, which is required when accessing the DeepL API through
/// a reverse proxy with a path prefix (e.g. <c>https://proxy.example.com/deepl/</c>). Set to <c>false</c> to ignore
/// the path component and resolve API paths from the server root.
/// </summary>
public bool PreserveServerUrlPath { get; set; } = true;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actually....not sure wether the current behaviour is true or false...


/// <summary>
/// Factory function returning an <see cref="HttpClient" /> to be used by <see cref="Translator" /> for HTTP requests,
/// and a flag whether to call <see cref="HttpClient.Dispose" /> in <see cref="Translator.Dispose" />.
Expand Down
61 changes: 61 additions & 0 deletions DeepLTests/GeneralTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,67 @@ public async Task TestServerUrlWithPathPrefix() {
Assert.StartsWith("https://external-api.example.com/deepl/", requestUri);
}

[Fact]
public async Task TestServerUrlPathIgnoredWhenPreserveDisabled() {
// Verify that when PreserveServerUrlPath is false, the path component of ServerUrl is ignored
// and API paths are resolved from the server root.
const string usageResponseJson = "{\"character_count\": 0, \"character_limit\": 0}";
var handler = getMockHandler(usageResponseJson);
var options = new TranslatorOptions {
ServerUrl = "https://external-api.example.com/deepl/",
PreserveServerUrlPath = false,
ClientFactory = () => new HttpClientAndDisposeFlag {
HttpClient = new HttpClient(handler), DisposeClient = true
}
};
var translator = new Translator("test-auth-key:fx", options);
await translator.GetUsageAsync();

Assert.Single(handler.requests);
var requestUri = handler.requests[0].RequestUri!.ToString();
Assert.StartsWith("https://external-api.example.com/v2/", requestUri);
}

[Fact]
public async Task TestServerUrlPathPreservedByDefault() {
// Verify that PreserveServerUrlPath defaults to true and the path prefix is preserved.
const string usageResponseJson = "{\"character_count\": 0, \"character_limit\": 0}";
var handler = getMockHandler(usageResponseJson);
var options = new TranslatorOptions {
ServerUrl = "https://external-api.example.com/deepl/",
ClientFactory = () => new HttpClientAndDisposeFlag {
HttpClient = new HttpClient(handler), DisposeClient = true
}
};
// Note: PreserveServerUrlPath is not explicitly set, relying on the default (true)
var translator = new Translator("test-auth-key:fx", options);
await translator.GetUsageAsync();

Assert.Single(handler.requests);
var requestUri = handler.requests[0].RequestUri!.ToString();
Assert.Equal("https://external-api.example.com/deepl/v2/usage", requestUri);
}

[Fact]
public async Task TestDeepLClientOptionsPreserveServerUrlPath() {
// Verify that the option works through DeepLClientOptions as well
const string usageResponseJson = "{\"character_count\": 0, \"character_limit\": 0}";
var handler = getMockHandler(usageResponseJson);
var options = new DeepLClientOptions {
ServerUrl = "https://external-api.example.com/deepl/",
PreserveServerUrlPath = false,
ClientFactory = () => new HttpClientAndDisposeFlag {
HttpClient = new HttpClient(handler), DisposeClient = true
}
};
var client = new DeepLClient("test-auth-key:fx", options);
await client.GetUsageAsync();

Assert.Single(handler.requests);
var requestUri = handler.requests[0].RequestUri!.ToString();
Assert.StartsWith("https://external-api.example.com/v2/", requestUri);
}

[Fact]
public async Task TestUsage() {
var translator = CreateTestTranslator();
Expand Down