From 5330a216b66e807fcba5df96e68ce5bf14530eba Mon Sep 17 00:00:00 2001 From: Tim Cadenbach Date: Tue, 31 Mar 2026 11:22:53 +0200 Subject: [PATCH 01/10] Draft for a DeepL Voice implementation --- DeepL/DeepL.csproj | 1 + DeepL/DeepLClient.cs | 76 ++++++++- DeepL/IVoiceManager.cs | 28 ++++ DeepL/IVoiceSession.cs | 77 +++++++++ DeepL/Model/TargetMediaChunk.cs | 68 ++++++++ DeepL/Model/TranscriptSegment.cs | 29 ++++ DeepL/Model/TranscriptUpdate.cs | 41 +++++ DeepL/Model/VoiceSessionInfo.cs | 40 +++++ DeepL/Model/VoiceStreamError.cs | 41 +++++ DeepL/SourceLanguageMode.cs | 29 ++++ DeepL/SourceMediaContentType.cs | 68 ++++++++ DeepL/TargetMediaVoice.cs | 32 ++++ DeepL/VoiceMessageFormat.cs | 29 ++++ DeepL/VoiceSession.cs | 258 +++++++++++++++++++++++++++++++ DeepL/VoiceSessionOptions.cs | 70 +++++++++ DeepLTests/VoiceSessionTest.cs | 180 +++++++++++++++++++++ 16 files changed, 1066 insertions(+), 1 deletion(-) create mode 100644 DeepL/IVoiceManager.cs create mode 100644 DeepL/IVoiceSession.cs create mode 100644 DeepL/Model/TargetMediaChunk.cs create mode 100644 DeepL/Model/TranscriptSegment.cs create mode 100644 DeepL/Model/TranscriptUpdate.cs create mode 100644 DeepL/Model/VoiceSessionInfo.cs create mode 100644 DeepL/Model/VoiceStreamError.cs create mode 100644 DeepL/SourceLanguageMode.cs create mode 100644 DeepL/SourceMediaContentType.cs create mode 100644 DeepL/TargetMediaVoice.cs create mode 100644 DeepL/VoiceMessageFormat.cs create mode 100644 DeepL/VoiceSession.cs create mode 100644 DeepL/VoiceSessionOptions.cs create mode 100644 DeepLTests/VoiceSessionTest.cs diff --git a/DeepL/DeepL.csproj b/DeepL/DeepL.csproj index f6319aa..c6c8400 100644 --- a/DeepL/DeepL.csproj +++ b/DeepL/DeepL.csproj @@ -34,6 +34,7 @@ + diff --git a/DeepL/DeepLClient.cs b/DeepL/DeepLClient.cs index 6a2dc91..73cb4d4 100644 --- a/DeepL/DeepLClient.cs +++ b/DeepL/DeepLClient.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Net.WebSockets; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading; @@ -54,7 +55,7 @@ Task RephraseTextAsync( /// Client for the DeepL API. To use the DeepL API, initialize an instance of this class using your DeepL /// Authentication Key. All functions are thread-safe, aside from . /// - public sealed class DeepLClient : Translator, IWriter, IGlossaryManager, IStyleRuleManager { + public sealed class DeepLClient : Translator, IWriter, IGlossaryManager, IStyleRuleManager, IVoiceManager { /// Initializes a new instance of the class. /// The message that describes the error. public DeepLClient(string authKey, DeepLClientOptions? options = null) : base(authKey, options) { } @@ -939,6 +940,79 @@ private static (string Key, string Value)[] CreateLanguageQueryParams( DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; + /// + public async Task CreateVoiceSessionAsync( + VoiceSessionOptions options, + CancellationToken cancellationToken = default) { + if (options == null) { + throw new ArgumentNullException(nameof(options)); + } + + if (options.TargetLanguages == null || options.TargetLanguages.Length == 0) { + throw new ArgumentException("At least one target language must be specified"); + } + + if (options.TargetLanguages.Length > 5) { + throw new ArgumentException("Maximum 5 target languages per session"); + } + + var requestData = new Dictionary { + ["source_media_content_type"] = options.SourceMediaContentType, + ["target_languages"] = options.TargetLanguages + }; + + if (options.MessageFormat != null) { + requestData["message_format"] = options.MessageFormat.Value.ToApiValue(); + } + + if (options.SourceLanguage != null) { + requestData["source_language"] = options.SourceLanguage; + } + + if (options.SourceLanguageMode != null) { + requestData["source_language_mode"] = options.SourceLanguageMode.Value.ToApiValue(); + } + + if (options.TargetMediaLanguages != null) { + requestData["target_media_languages"] = options.TargetMediaLanguages; + } + + if (options.TargetMediaContentType != null) { + requestData["target_media_content_type"] = options.TargetMediaContentType; + } + + if (options.TargetMediaVoice != null) { + requestData["target_media_voice"] = options.TargetMediaVoice.Value.ToApiValue(); + } + + if (options.GlossaryId != null) { + requestData["glossary_id"] = options.GlossaryId; + } + + if (options.Formality != null) { + requestData["formality"] = options.Formality; + } + + using var responseMessage = await _client + .ApiPostJsonAsync("v3/voice/realtime", cancellationToken, requestData, SerializationOptions) + .ConfigureAwait(false); + + await DeepLHttpClient.CheckStatusCodeAsync(responseMessage).ConfigureAwait(false); + var sessionInfo = await JsonUtils.DeserializeAsync(responseMessage).ConfigureAwait(false); + + // Establish WebSocket connection + var wsUri = new Uri($"{sessionInfo.StreamingUrl}?token={Uri.EscapeDataString(sessionInfo.Token)}"); + var webSocket = new ClientWebSocket(); + try { + await webSocket.ConnectAsync(wsUri, cancellationToken).ConfigureAwait(false); + } catch (Exception ex) { + webSocket.Dispose(); + throw new DeepLException("Failed to establish Voice API WebSocket connection", ex); + } + + return new VoiceSession(_client, webSocket, sessionInfo); + } + /// Class used for JSON-deserialization of style rule list results. private readonly struct StyleRuleListResult { /// Initializes a new instance of , used for JSON deserialization. diff --git a/DeepL/IVoiceManager.cs b/DeepL/IVoiceManager.cs new file mode 100644 index 0000000..afc2e6f --- /dev/null +++ b/DeepL/IVoiceManager.cs @@ -0,0 +1,28 @@ +// Copyright 2025 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; + +namespace DeepL { + /// Interface for creating Voice API streaming sessions. + public interface IVoiceManager : IDisposable { + /// + /// Creates a new Voice API streaming session for real-time speech transcription and translation. + /// This requests a session from the DeepL API and establishes a WebSocket connection. + /// + /// Options controlling session configuration including audio format, languages, etc. + /// The cancellation token to cancel the operation. + /// An for streaming audio and receiving transcripts. + /// If any option is invalid. + /// + /// If any error occurs while communicating with the DeepL API, a + /// or a derived class will be thrown. + /// + Task CreateVoiceSessionAsync( + VoiceSessionOptions options, + CancellationToken cancellationToken = default); + } +} diff --git a/DeepL/IVoiceSession.cs b/DeepL/IVoiceSession.cs new file mode 100644 index 0000000..d5d0e6c --- /dev/null +++ b/DeepL/IVoiceSession.cs @@ -0,0 +1,77 @@ +// Copyright 2025 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.Model; + +namespace DeepL { + /// + /// Represents an active Voice API streaming session. Provides methods for sending audio data and receiving + /// real-time transcriptions and translations via events. + /// + /// + /// Events fire on a background thread. Consumers are responsible for marshaling to the appropriate + /// synchronization context if needed. Dispose the session to close the WebSocket connection. + /// + public interface IVoiceSession : IDisposable { + /// Raised when a source transcript update is received from the server. + event EventHandler? SourceTranscriptUpdated; + + /// Raised when a target transcript update is received from the server. + event EventHandler? TargetTranscriptUpdated; + + /// + /// Raised when a target media audio chunk is received from the server. This feature is in closed beta. + /// + event EventHandler? TargetMediaChunkReceived; + + /// Raised when an error message is received from the WebSocket connection. + event EventHandler? ErrorReceived; + + /// Raised when the end-of-stream message is received, indicating all outputs are complete. + event EventHandler? StreamEnded; + + /// The unique session identifier. + string? SessionId { get; } + + /// Whether the WebSocket connection is currently open. + bool IsConnected { get; } + + /// + /// Sends a chunk of audio data to the server. The audio encoding must match the + /// specified when creating the session. + /// + /// Audio data to send. Must not exceed 100 KB or 1 second duration. + /// The cancellation token to cancel the operation. + /// If the session is not connected or sending fails. + Task SendAudioAsync(byte[] audioData, CancellationToken cancellationToken = default); + + /// + /// Sends a chunk of audio data to the server using a memory-efficient overload. + /// + /// Audio data to send. Must not exceed 100 KB or 1 second duration. + /// The cancellation token to cancel the operation. + /// If the session is not connected or sending fails. + Task SendAudioAsync(ArraySegment audioData, CancellationToken cancellationToken = default); + + /// + /// Signals the end of the audio stream. Causes finalization of tentative transcript segments and + /// triggers emission of final transcript updates, end-of-transcript, and end-of-stream messages. + /// No more audio data can be sent after calling this method. + /// + /// The cancellation token to cancel the operation. + /// If the session is not connected or sending fails. + Task EndAudioAsync(CancellationToken cancellationToken = default); + + /// + /// Requests a reconnection token and establishes a new WebSocket connection, resuming the session. + /// This should be called when the WebSocket connection is lost unexpectedly. + /// + /// The cancellation token to cancel the operation. + /// If reconnection fails. + Task ReconnectAsync(CancellationToken cancellationToken = default); + } +} diff --git a/DeepL/Model/TargetMediaChunk.cs b/DeepL/Model/TargetMediaChunk.cs new file mode 100644 index 0000000..f6b1522 --- /dev/null +++ b/DeepL/Model/TargetMediaChunk.cs @@ -0,0 +1,68 @@ +// Copyright 2025 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.Text.Json.Serialization; + +namespace DeepL.Model { + /// + /// Represents a translated audio chunk from the Voice API. This feature is currently in closed beta. + /// Audio data is provided as an array of base64-encoded indivisible chunks. + /// + public sealed class TargetMediaChunk { + /// Initializes a new instance of . + /// The content type of the audio data. Present in the first message. + /// Number of header packets at the start of the data array, or null if all are audio. + /// Array of base64-encoded audio data packets. + /// Text corresponding to this audio chunk, for subtitle synchronization. + /// The target language of this audio chunk. + /// Duration of this audio chunk in seconds. + /// + /// The constructor for this class (and all other Model classes) should not be used by library users. Ideally it + /// would be marked , but needs to be for JSON deserialization. + /// In future this function may have backwards-incompatible changes. + /// + [JsonConstructor] + public TargetMediaChunk( + string? contentType, + int? headers, + string[] data, + string? text, + string? language, + double? duration) { + ContentType = contentType; + Headers = headers; + Data = data; + Text = text; + Language = language; + Duration = duration; + } + + /// The content type of the audio data. Present in the first message of a sequence. + [JsonPropertyName("content_type")] + public string? ContentType { get; } + + /// + /// Number of packets at the start of that contain initialization/header data. + /// Null or absent when all packets are audio data. + /// + [JsonPropertyName("headers")] + public int? Headers { get; } + + /// Array of base64-encoded indivisible audio data packets. + [JsonPropertyName("data")] + public string[] Data { get; } + + /// Text corresponding to this audio chunk, for subtitle synchronization. + [JsonPropertyName("text")] + public string? Text { get; } + + /// The target language of this audio chunk. + [JsonPropertyName("language")] + public string? Language { get; } + + /// Duration of this audio chunk in seconds. + [JsonPropertyName("duration")] + public double? Duration { get; } + } +} diff --git a/DeepL/Model/TranscriptSegment.cs b/DeepL/Model/TranscriptSegment.cs new file mode 100644 index 0000000..b678ce2 --- /dev/null +++ b/DeepL/Model/TranscriptSegment.cs @@ -0,0 +1,29 @@ +// Copyright 2025 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.Text.Json.Serialization; + +namespace DeepL.Model { + /// A single text segment within a Voice API transcript update. + public sealed class TranscriptSegment { + /// Initializes a new instance of . + /// The text content of this segment. + /// + /// The constructor for this class (and all other Model classes) should not be used by library users. Ideally it + /// would be marked , but needs to be for JSON deserialization. + /// In future this function may have backwards-incompatible changes. + /// + [JsonConstructor] + public TranscriptSegment(string text) { + Text = text; + } + + /// The text content of this segment. + [JsonPropertyName("text")] + public string Text { get; } + + /// Returns the text content of this segment. + public override string ToString() => Text; + } +} diff --git a/DeepL/Model/TranscriptUpdate.cs b/DeepL/Model/TranscriptUpdate.cs new file mode 100644 index 0000000..9db2adc --- /dev/null +++ b/DeepL/Model/TranscriptUpdate.cs @@ -0,0 +1,41 @@ +// Copyright 2025 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.Text.Json.Serialization; + +namespace DeepL.Model { + /// + /// Represents a transcript update from the Voice API, containing concluded (finalized) and tentative + /// (in-progress) text segments. Used for both source and target transcript updates. + /// + public sealed class TranscriptUpdate { + /// Initializes a new instance of . + /// Finalized text segments that will not change. + /// Preliminary text segments that may be refined. + /// The language code of this transcript update. Only present on target updates. + /// + /// The constructor for this class (and all other Model classes) should not be used by library users. Ideally it + /// would be marked , but needs to be for JSON deserialization. + /// In future this function may have backwards-incompatible changes. + /// + [JsonConstructor] + public TranscriptUpdate(TranscriptSegment[] concluded, TranscriptSegment[] tentative, string? language) { + Concluded = concluded; + Tentative = tentative; + Language = language; + } + + /// Finalized text segments that will not change. These segments are sent once and remain fixed. + [JsonPropertyName("concluded")] + public TranscriptSegment[] Concluded { get; } + + /// Preliminary text segments that may be refined as more audio context becomes available. + [JsonPropertyName("tentative")] + public TranscriptSegment[] Tentative { get; } + + /// The language code of this transcript update. Only present on target transcript updates. + [JsonPropertyName("language")] + public string? Language { get; } + } +} diff --git a/DeepL/Model/VoiceSessionInfo.cs b/DeepL/Model/VoiceSessionInfo.cs new file mode 100644 index 0000000..45aa899 --- /dev/null +++ b/DeepL/Model/VoiceSessionInfo.cs @@ -0,0 +1,40 @@ +// Copyright 2025 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.Text.Json.Serialization; + +namespace DeepL.Model { + /// Information about a Voice API session, received from the session request endpoint. + public sealed class VoiceSessionInfo { + /// Initializes a new instance of . + /// The WebSocket URL for establishing the stream connection. + /// Ephemeral authentication token for the streaming endpoint. + /// Unique identifier for the session. + /// + /// The constructor for this class (and all other Model classes) should not be used by library users. Ideally it + /// would be marked , but needs to be for JSON deserialization. + /// In future this function may have backwards-incompatible changes. + /// + [JsonConstructor] + public VoiceSessionInfo(string streamingUrl, string token, string? sessionId) { + StreamingUrl = streamingUrl; + Token = token; + SessionId = sessionId; + } + + /// The WebSocket URL to use for establishing the stream connection. + [JsonPropertyName("streaming_url")] + public string StreamingUrl { get; } + + /// + /// Ephemeral authentication token for the streaming endpoint. Valid for one-time use only. + /// + [JsonPropertyName("token")] + public string Token { get; } + + /// Unique identifier for the session. + [JsonPropertyName("session_id")] + public string? SessionId { get; } + } +} diff --git a/DeepL/Model/VoiceStreamError.cs b/DeepL/Model/VoiceStreamError.cs new file mode 100644 index 0000000..80a0311 --- /dev/null +++ b/DeepL/Model/VoiceStreamError.cs @@ -0,0 +1,41 @@ +// Copyright 2025 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.Text.Json.Serialization; + +namespace DeepL.Model { + /// Represents an error message received from the Voice API WebSocket connection. + public sealed class VoiceStreamError { + /// Initializes a new instance of . + /// The error code. + /// The reason code for the error. + /// A human-readable error message. + /// + /// The constructor for this class (and all other Model classes) should not be used by library users. Ideally it + /// would be marked , but needs to be for JSON deserialization. + /// In future this function may have backwards-incompatible changes. + /// + [JsonConstructor] + public VoiceStreamError(string? code, string? reason, string? message) { + Code = code; + Reason = reason; + Message = message; + } + + /// The error code. + [JsonPropertyName("code")] + public string? Code { get; } + + /// The reason code for the error. + [JsonPropertyName("reason")] + public string? Reason { get; } + + /// A human-readable error message. + [JsonPropertyName("message")] + public string? Message { get; } + + /// Returns the error message. + public override string ToString() => $"VoiceStreamError(code={Code}, reason={Reason}, message={Message})"; + } +} diff --git a/DeepL/SourceLanguageMode.cs b/DeepL/SourceLanguageMode.cs new file mode 100644 index 0000000..521037f --- /dev/null +++ b/DeepL/SourceLanguageMode.cs @@ -0,0 +1,29 @@ +// Copyright 2025 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; + +namespace DeepL { + /// Controls how the source language value is used in Voice API sessions. + public enum SourceLanguageMode { + /// Treats source language as a hint; server can override. + Auto, + + /// Treats source language as mandatory; server must use this language. + Fixed + } + + /// Extension methods for . + public static class SourceLanguageModeExtensions { + /// Retrieves the string representation used by the DeepL API. + /// If an unknown enum value is passed. + public static string ToApiValue(this SourceLanguageMode mode) { + return mode switch { + SourceLanguageMode.Auto => "auto", + SourceLanguageMode.Fixed => "fixed", + _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unrecognized source language mode value") + }; + } + } +} diff --git a/DeepL/SourceMediaContentType.cs b/DeepL/SourceMediaContentType.cs new file mode 100644 index 0000000..fe48105 --- /dev/null +++ b/DeepL/SourceMediaContentType.cs @@ -0,0 +1,68 @@ +// Copyright 2025 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 { + /// + /// String constants for audio format content types supported by the DeepL Voice API. + /// Use these when configuring . + /// + public static class SourceMediaContentType { + /// Auto-detect container and codec. Supported for all formats except PCM. + public const string Auto = "audio/auto"; + + /// FLAC container with FLAC codec. + public const string Flac = "audio/flac"; + + /// MPEG container with MP3 codec. + public const string Mpeg = "audio/mpeg"; + + /// Ogg container with auto-detected codec (FLAC or OPUS). + public const string Ogg = "audio/ogg"; + + /// WebM container with OPUS codec. + public const string WebM = "audio/webm"; + + /// Matroska container with auto-detected codec. + public const string Matroska = "audio/x-matroska"; + + /// Ogg container with FLAC codec. + public const string OggFlac = "audio/ogg;codecs=flac"; + + /// Ogg container with OPUS codec. + public const string OggOpus = "audio/ogg;codecs=opus"; + + /// PCM signed 16-bit little-endian at 8000 Hz. + public const string PcmS16le8000 = "audio/pcm;encoding=s16le;rate=8000"; + + /// PCM signed 16-bit little-endian at 16000 Hz. Recommended for general use. + public const string PcmS16le16000 = "audio/pcm;encoding=s16le;rate=16000"; + + /// PCM signed 16-bit little-endian at 44100 Hz. + public const string PcmS16le44100 = "audio/pcm;encoding=s16le;rate=44100"; + + /// PCM signed 16-bit little-endian at 48000 Hz. + public const string PcmS16le48000 = "audio/pcm;encoding=s16le;rate=48000"; + + /// PCM A-Law at 8000 Hz (G.711). + public const string PcmAlaw8000 = "audio/pcm;encoding=alaw;rate=8000"; + + /// PCM µ-Law at 8000 Hz (G.711). + public const string PcmUlaw8000 = "audio/pcm;encoding=ulaw;rate=8000"; + + /// WebM container with OPUS codec (explicit). + public const string WebMOpus = "audio/webm;codecs=opus"; + + /// Matroska container with AAC codec. + public const string MatroskaAac = "audio/x-matroska;codecs=aac"; + + /// Matroska container with FLAC codec. + public const string MatroskaFlac = "audio/x-matroska;codecs=flac"; + + /// Matroska container with MP3 codec. + public const string MatroskaMp3 = "audio/x-matroska;codecs=mp3"; + + /// Matroska container with OPUS codec. + public const string MatroskaOpus = "audio/x-matroska;codecs=opus"; + } +} diff --git a/DeepL/TargetMediaVoice.cs b/DeepL/TargetMediaVoice.cs new file mode 100644 index 0000000..10b5c33 --- /dev/null +++ b/DeepL/TargetMediaVoice.cs @@ -0,0 +1,32 @@ +// Copyright 2025 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; + +namespace DeepL { + /// + /// Target audio voice selection for synthesized speech in Voice API sessions. + /// This feature is currently in closed beta. + /// + public enum TargetMediaVoice { + /// Male voice. + Male, + + /// Female voice. + Female + } + + /// Extension methods for . + public static class TargetMediaVoiceExtensions { + /// Retrieves the string representation used by the DeepL API. + /// If an unknown enum value is passed. + public static string ToApiValue(this TargetMediaVoice voice) { + return voice switch { + TargetMediaVoice.Male => "male", + TargetMediaVoice.Female => "female", + _ => throw new ArgumentOutOfRangeException(nameof(voice), voice, "Unrecognized target media voice value") + }; + } + } +} diff --git a/DeepL/VoiceMessageFormat.cs b/DeepL/VoiceMessageFormat.cs new file mode 100644 index 0000000..d4aace6 --- /dev/null +++ b/DeepL/VoiceMessageFormat.cs @@ -0,0 +1,29 @@ +// Copyright 2025 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; + +namespace DeepL { + /// Message encoding format for Voice API WebSocket communication. + public enum VoiceMessageFormat { + /// JSON-encoded messages sent as TEXT WebSocket frames. Binary fields are base64-encoded. + Json, + + /// MessagePack-encoded messages sent as BINARY WebSocket frames. Binary fields are raw binary. + MessagePack + } + + /// Extension methods for . + public static class VoiceMessageFormatExtensions { + /// Retrieves the string representation used by the DeepL API. + /// If an unknown enum value is passed. + public static string ToApiValue(this VoiceMessageFormat format) { + return format switch { + VoiceMessageFormat.Json => "json", + VoiceMessageFormat.MessagePack => "msgpack", + _ => throw new ArgumentOutOfRangeException(nameof(format), format, "Unrecognized message format value") + }; + } + } +} diff --git a/DeepL/VoiceSession.cs b/DeepL/VoiceSession.cs new file mode 100644 index 0000000..0ff826f --- /dev/null +++ b/DeepL/VoiceSession.cs @@ -0,0 +1,258 @@ +// Copyright 2025 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.WebSockets; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using DeepL.Internal; +using DeepL.Model; + +namespace DeepL { + /// + /// Internal implementation of that manages a WebSocket connection + /// to the DeepL Voice API for real-time speech transcription and translation. + /// + internal sealed class VoiceSession : IVoiceSession { + private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + private readonly DeepLHttpClient _httpClient; + private readonly object _lock = new object(); + private ClientWebSocket _webSocket; + private CancellationTokenSource _receiveCts; + private Task? _receiveTask; + private string _lastToken; + private bool _disposed; + + /// + public event EventHandler? SourceTranscriptUpdated; + + /// + public event EventHandler? TargetTranscriptUpdated; + + /// + public event EventHandler? TargetMediaChunkReceived; + + /// + public event EventHandler? ErrorReceived; + + /// + public event EventHandler? StreamEnded; + + /// + public string? SessionId { get; private set; } + + /// + public bool IsConnected { + get { + lock (_lock) { + return !_disposed && _webSocket.State == WebSocketState.Open; + } + } + } + + internal VoiceSession( + DeepLHttpClient httpClient, + ClientWebSocket webSocket, + VoiceSessionInfo sessionInfo) { + _httpClient = httpClient; + _webSocket = webSocket; + _lastToken = sessionInfo.Token; + SessionId = sessionInfo.SessionId; + _receiveCts = new CancellationTokenSource(); + _receiveTask = Task.Run(() => ReceiveLoopAsync(_receiveCts.Token)); + } + + /// + public async Task SendAudioAsync(byte[] audioData, CancellationToken cancellationToken = default) { + await SendAudioAsync(new ArraySegment(audioData), cancellationToken).ConfigureAwait(false); + } + + /// + public async Task SendAudioAsync(ArraySegment audioData, CancellationToken cancellationToken = default) { + EnsureConnected(); + + var base64Data = Convert.ToBase64String( + audioData.Array ?? throw new ArgumentException("Audio data array is null"), + audioData.Offset, + audioData.Count); + var message = $"{{\"source_media_chunk\":{{\"data\":\"{base64Data}\"}}}}"; + var bytes = Encoding.UTF8.GetBytes(message); + + await _webSocket.SendAsync( + new ArraySegment(bytes), + WebSocketMessageType.Text, + endOfMessage: true, + cancellationToken).ConfigureAwait(false); + } + + /// + public async Task EndAudioAsync(CancellationToken cancellationToken = default) { + EnsureConnected(); + + var message = "{\"end_of_source_media\":{}}"; + var bytes = Encoding.UTF8.GetBytes(message); + + await _webSocket.SendAsync( + new ArraySegment(bytes), + WebSocketMessageType.Text, + endOfMessage: true, + cancellationToken).ConfigureAwait(false); + } + + /// + public async Task ReconnectAsync(CancellationToken cancellationToken = default) { + // Stop current receive loop + _receiveCts.Cancel(); + if (_receiveTask != null) { + try { + await _receiveTask.ConfigureAwait(false); + } catch (OperationCanceledException) { + // Expected + } + } + + // Close existing WebSocket if still open + if (_webSocket.State == WebSocketState.Open || _webSocket.State == WebSocketState.CloseReceived) { + try { + await _webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Reconnecting", CancellationToken.None) + .ConfigureAwait(false); + } catch (WebSocketException) { + // Ignore close errors during reconnection + } + } + + _webSocket.Dispose(); + + // Request new token via GET v3/voice/realtime?token= + var queryParams = new[] { ("token", _lastToken) }; + using var responseMessage = await _httpClient.ApiGetAsync("v3/voice/realtime", cancellationToken, queryParams) + .ConfigureAwait(false); + await DeepLHttpClient.CheckStatusCodeAsync(responseMessage).ConfigureAwait(false); + var sessionInfo = await JsonUtils.DeserializeAsync(responseMessage).ConfigureAwait(false); + + _lastToken = sessionInfo.Token; + SessionId = sessionInfo.SessionId; + + // Establish new WebSocket connection + var wsUri = new Uri($"{sessionInfo.StreamingUrl}?token={Uri.EscapeDataString(sessionInfo.Token)}"); + _webSocket = new ClientWebSocket(); + await _webSocket.ConnectAsync(wsUri, cancellationToken).ConfigureAwait(false); + + // Restart receive loop + _receiveCts = new CancellationTokenSource(); + _receiveTask = Task.Run(() => ReceiveLoopAsync(_receiveCts.Token)); + } + + /// Background loop that receives and dispatches WebSocket messages. + private async Task ReceiveLoopAsync(CancellationToken cancellationToken) { + var buffer = new byte[64 * 1024]; // 64 KB buffer + var messageBuilder = new StringBuilder(); + + try { + while (!cancellationToken.IsCancellationRequested && + _webSocket.State == WebSocketState.Open) { + messageBuilder.Clear(); + WebSocketReceiveResult result; + do { + result = await _webSocket.ReceiveAsync( + new ArraySegment(buffer), cancellationToken).ConfigureAwait(false); + + if (result.MessageType == WebSocketMessageType.Close) { + return; + } + + if (result.MessageType == WebSocketMessageType.Text) { + messageBuilder.Append(Encoding.UTF8.GetString(buffer, 0, result.Count)); + } + } while (!result.EndOfMessage); + + if (messageBuilder.Length > 0) { + DispatchMessage(messageBuilder.ToString()); + } + } + } catch (OperationCanceledException) { + // Normal cancellation + } catch (WebSocketException) { + // Connection lost — consumer should call ReconnectAsync + } + } + + /// Parses a JSON message from the WebSocket and dispatches it to the appropriate event. + private void DispatchMessage(string json) { + try { + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + + if (root.TryGetProperty("source_transcript_update", out var sourceUpdate)) { + var update = JsonSerializer.Deserialize(sourceUpdate.GetRawText(), JsonOptions); + if (update != null) { + SourceTranscriptUpdated?.Invoke(this, update); + } + } else if (root.TryGetProperty("target_transcript_update", out var targetUpdate)) { + var update = JsonSerializer.Deserialize(targetUpdate.GetRawText(), JsonOptions); + if (update != null) { + TargetTranscriptUpdated?.Invoke(this, update); + } + } else if (root.TryGetProperty("target_media_chunk", out var mediaChunk)) { + var chunk = JsonSerializer.Deserialize(mediaChunk.GetRawText(), JsonOptions); + if (chunk != null) { + TargetMediaChunkReceived?.Invoke(this, chunk); + } + } else if (root.TryGetProperty("end_of_source_transcript", out _)) { + // Source transcript complete — no special event needed, handled via StreamEnded + } else if (root.TryGetProperty("end_of_target_transcript", out _)) { + // Target transcript complete — no special event needed, handled via StreamEnded + } else if (root.TryGetProperty("end_of_target_media", out _)) { + // Target media complete — no special event needed, handled via StreamEnded + } else if (root.TryGetProperty("end_of_stream", out _)) { + StreamEnded?.Invoke(this, EventArgs.Empty); + } else if (root.TryGetProperty("error", out var errorElement)) { + var error = JsonSerializer.Deserialize(errorElement.GetRawText(), JsonOptions); + if (error != null) { + ErrorReceived?.Invoke(this, error); + } + } + } catch (JsonException) { + // Ignore malformed messages + } + } + + private void EnsureConnected() { + if (_disposed) { + throw new ObjectDisposedException(nameof(VoiceSession)); + } + + if (_webSocket.State != WebSocketState.Open) { + throw new DeepLException("Voice session WebSocket is not connected"); + } + } + + /// Releases the WebSocket connection and stops the receive loop. + public void Dispose() { + lock (_lock) { + if (_disposed) return; + _disposed = true; + } + + _receiveCts.Cancel(); + + try { + if (_webSocket.State == WebSocketState.Open) { + _webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Disposing", CancellationToken.None) + .GetAwaiter().GetResult(); + } + } catch (WebSocketException) { + // Ignore errors during disposal + } + + _webSocket.Dispose(); + _receiveCts.Dispose(); + } + } +} diff --git a/DeepL/VoiceSessionOptions.cs b/DeepL/VoiceSessionOptions.cs new file mode 100644 index 0000000..cf1235c --- /dev/null +++ b/DeepL/VoiceSessionOptions.cs @@ -0,0 +1,70 @@ +// Copyright 2025 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 { + /// + /// Options to control Voice API session creation. These options are provided to + /// . + /// + public sealed class VoiceSessionOptions { + /// Initializes a new object. + public VoiceSessionOptions() { } + + /// + /// The audio format for streaming, which specifies container, codec, and encoding parameters. + /// Use constants from for supported values. Required. + /// + public string SourceMediaContentType { get; set; } = DeepL.SourceMediaContentType.Auto; + + /// + /// Message encoding format for WebSocket communication. Defaults to . + /// + public VoiceMessageFormat? MessageFormat { get; set; } + + /// + /// The source language of the audio stream, or null for auto-detection. + /// Must be a supported Voice API source language complying with IETF BCP 47 language tags. + /// + public string? SourceLanguage { get; set; } + + /// + /// Controls how the value is used. + /// Defaults to if not specified. + /// + public SourceLanguageMode? SourceLanguageMode { get; set; } + + /// + /// List of target languages for translation. The stream will emit translations for each language. + /// Maximum 5 target languages per session. Language identifiers must comply with IETF BCP 47. + /// + public string[] TargetLanguages { get; set; } = System.Array.Empty(); + + /// + /// List of target languages for which to generate synthesized audio. This feature is in closed beta. + /// Languages specified here will automatically be added to if not already present. + /// Maximum 5 target media languages per session. + /// + public string[]? TargetMediaLanguages { get; set; } + + /// + /// The audio format for synthesized target media streaming. This feature is in closed beta. + /// Defaults to "audio/webm;codecs=opus" if not specified. + /// + public string? TargetMediaContentType { get; set; } + + /// + /// Target audio voice selection for synthesized speech. This feature is in closed beta. + /// + public TargetMediaVoice? TargetMediaVoice { get; set; } + + /// A glossary ID to use for translation. + public string? GlossaryId { get; set; } + + /// + /// Sets whether the translated text should lean towards formal or informal language. + /// Possible values: "default", "formal", "more", "informal", "less". + /// + public string? Formality { get; set; } + } +} diff --git a/DeepLTests/VoiceSessionTest.cs b/DeepLTests/VoiceSessionTest.cs new file mode 100644 index 0000000..6f494a4 --- /dev/null +++ b/DeepLTests/VoiceSessionTest.cs @@ -0,0 +1,180 @@ +// Copyright 2025 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.Text.Json; +using System.Threading.Tasks; +using DeepL; +using DeepL.Model; +using Xunit; + +namespace DeepLTests { + /// Unit tests for Voice API types that do not require API access. + public sealed class VoiceSessionUnitTest { + [Fact] + public void TestVoiceSessionOptionsDefaults() { + var options = new VoiceSessionOptions(); + Assert.Equal(SourceMediaContentType.Auto, options.SourceMediaContentType); + Assert.Null(options.MessageFormat); + Assert.Null(options.SourceLanguage); + Assert.Null(options.SourceLanguageMode); + Assert.NotNull(options.TargetLanguages); + Assert.Empty(options.TargetLanguages); + Assert.Null(options.TargetMediaLanguages); + Assert.Null(options.TargetMediaContentType); + Assert.Null(options.TargetMediaVoice); + Assert.Null(options.GlossaryId); + Assert.Null(options.Formality); + } + + [Fact] + public void TestVoiceSessionOptionsConfiguration() { + var options = new VoiceSessionOptions { + SourceMediaContentType = SourceMediaContentType.OggOpus, + MessageFormat = VoiceMessageFormat.Json, + SourceLanguage = "en", + SourceLanguageMode = DeepL.SourceLanguageMode.Fixed, + TargetLanguages = new[] { "de", "fr", "es" }, + TargetMediaVoice = TargetMediaVoice.Female, + GlossaryId = "test-glossary-id", + Formality = "formal" + }; + + Assert.Equal(SourceMediaContentType.OggOpus, options.SourceMediaContentType); + Assert.Equal(VoiceMessageFormat.Json, options.MessageFormat); + Assert.Equal("en", options.SourceLanguage); + Assert.Equal(DeepL.SourceLanguageMode.Fixed, options.SourceLanguageMode); + Assert.Equal(3, options.TargetLanguages.Length); + Assert.Equal(TargetMediaVoice.Female, options.TargetMediaVoice); + Assert.Equal("test-glossary-id", options.GlossaryId); + Assert.Equal("formal", options.Formality); + } + + [Fact] + public void TestVoiceMessageFormatApiValues() { + Assert.Equal("json", VoiceMessageFormat.Json.ToApiValue()); + Assert.Equal("msgpack", VoiceMessageFormat.MessagePack.ToApiValue()); + } + + [Fact] + public void TestSourceLanguageModeApiValues() { + Assert.Equal("auto", DeepL.SourceLanguageMode.Auto.ToApiValue()); + Assert.Equal("fixed", DeepL.SourceLanguageMode.Fixed.ToApiValue()); + } + + [Fact] + public void TestTargetMediaVoiceApiValues() { + Assert.Equal("male", TargetMediaVoice.Male.ToApiValue()); + Assert.Equal("female", TargetMediaVoice.Female.ToApiValue()); + } + + [Fact] + public void TestVoiceSessionInfoDeserialization() { + var json = "{\"streaming_url\":\"wss://api.deepl.com/v3/voice/realtime/connect\"," + + "\"token\":\"test-token-123\"," + + "\"session_id\":\"test-session-456\"}"; + var info = JsonSerializer.Deserialize(json); + Assert.NotNull(info); + Assert.Equal("wss://api.deepl.com/v3/voice/realtime/connect", info!.StreamingUrl); + Assert.Equal("test-token-123", info.Token); + Assert.Equal("test-session-456", info.SessionId); + } + + [Fact] + public void TestTranscriptUpdateDeserialization() { + var json = "{\"concluded\":[{\"text\":\"Hello \"}],\"tentative\":[{\"text\":\"world\"}],\"language\":\"de\"}"; + var update = JsonSerializer.Deserialize(json); + Assert.NotNull(update); + Assert.Single(update!.Concluded); + Assert.Equal("Hello ", update.Concluded[0].Text); + Assert.Single(update.Tentative); + Assert.Equal("world", update.Tentative[0].Text); + Assert.Equal("de", update.Language); + } + + [Fact] + public void TestTranscriptSegmentDeserialization() { + var json = "{\"text\":\"Hello world\"}"; + var segment = JsonSerializer.Deserialize(json); + Assert.NotNull(segment); + Assert.Equal("Hello world", segment!.Text); + Assert.Equal("Hello world", segment.ToString()); + } + + [Fact] + public void TestTargetMediaChunkDeserialization() { + var json = "{\"content_type\":\"audio/webm;codecs=opus\"," + + "\"headers\":1," + + "\"data\":[\"base64data1\",\"base64data2\"]," + + "\"text\":\"Hallo Welt\"," + + "\"language\":\"de\"," + + "\"duration\":1.5}"; + var chunk = JsonSerializer.Deserialize(json); + Assert.NotNull(chunk); + Assert.Equal("audio/webm;codecs=opus", chunk!.ContentType); + Assert.Equal(1, chunk.Headers); + Assert.Equal(2, chunk.Data.Length); + Assert.Equal("base64data1", chunk.Data[0]); + Assert.Equal("Hallo Welt", chunk.Text); + Assert.Equal("de", chunk.Language); + Assert.Equal(1.5, chunk.Duration); + } + + [Fact] + public void TestVoiceStreamErrorDeserialization() { + var json = "{\"code\":\"4001\",\"reason\":\"invalid_audio\",\"message\":\"Audio format not supported\"}"; + var error = JsonSerializer.Deserialize(json); + Assert.NotNull(error); + Assert.Equal("4001", error!.Code); + Assert.Equal("invalid_audio", error.Reason); + Assert.Equal("Audio format not supported", error.Message); + } + + [Fact] + public void TestSourceMediaContentTypeConstants() { + Assert.Equal("audio/auto", SourceMediaContentType.Auto); + Assert.Equal("audio/flac", SourceMediaContentType.Flac); + Assert.Equal("audio/mpeg", SourceMediaContentType.Mpeg); + Assert.Equal("audio/ogg", SourceMediaContentType.Ogg); + Assert.Equal("audio/webm", SourceMediaContentType.WebM); + Assert.Equal("audio/x-matroska", SourceMediaContentType.Matroska); + Assert.Equal("audio/ogg;codecs=flac", SourceMediaContentType.OggFlac); + Assert.Equal("audio/ogg;codecs=opus", SourceMediaContentType.OggOpus); + Assert.Equal("audio/pcm;encoding=s16le;rate=16000", SourceMediaContentType.PcmS16le16000); + Assert.Equal("audio/webm;codecs=opus", SourceMediaContentType.WebMOpus); + } + } + + /// Tests for Voice API session creation that require API access. + public sealed class VoiceSessionClientTest : BaseDeepLTest { + [Fact] + public async Task TestCreateSessionRequiresTargetLanguages() { + var client = CreateTestClient(); + var options = new VoiceSessionOptions { + SourceMediaContentType = SourceMediaContentType.OggOpus + }; + await Assert.ThrowsAsync( + () => client.CreateVoiceSessionAsync(options)); + } + + [Fact] + public async Task TestCreateSessionRejectsExcessiveTargetLanguages() { + var client = CreateTestClient(); + var options = new VoiceSessionOptions { + SourceMediaContentType = SourceMediaContentType.OggOpus, + TargetLanguages = new[] { "de", "fr", "es", "it", "nl", "pt" } + }; + await Assert.ThrowsAsync( + () => client.CreateVoiceSessionAsync(options)); + } + + [Fact] + public async Task TestCreateSessionRejectsNullOptions() { + var client = CreateTestClient(); + await Assert.ThrowsAsync( + () => client.CreateVoiceSessionAsync(null!)); + } + } +} From 220a5d8fc84fca2dba7b8b0af3d8d45b61a0c0d4 Mon Sep 17 00:00:00 2001 From: Tim Cadenbach Date: Fri, 24 Apr 2026 11:49:38 +0200 Subject: [PATCH 02/10] feat: Add fluent API layer for translation, rephrase, documents, glossaries, and style rules Adds a LINQ-style fluent layer on top of the existing ITranslator / IWriter / IGlossaryManager / IStyleRuleManager surfaces. Builders are directly awaitable; every fluent method is an extension over the existing interfaces, so the new API is non-breaking and works with any ITranslator/IWriter implementation (including mocks). Examples: await translator.Translate("Hello").From("en").To("de") .WithFormality(Formality.More) .WithGlossary(glossary); await client.CreateGlossary("My glossary") .WithDictionary("en", "de", entries); await translator.TranslateDocument(input) .To("de") .WithFormality(Formality.More) .SaveTo(output); Includes 73 unit tests (via NSubstitute) covering argument forwarding, option-configuration helpers, validation, and both single/batch shapes. Co-Authored-By: Claude Opus 4.7 (1M context) --- DeepL/FluentDocumentTranslation.cs | 296 ++++++++++++++++ DeepL/FluentGlossary.cs | 313 ++++++++++++++++ DeepL/FluentStyleRule.cs | 254 +++++++++++++ DeepL/FluentTranslation.cs | 374 ++++++++++++++++++++ DeepLTests/FluentDocumentTranslationTest.cs | 284 +++++++++++++++ DeepLTests/FluentGlossaryTest.cs | 269 ++++++++++++++ DeepLTests/FluentStyleRuleTest.cs | 247 +++++++++++++ DeepLTests/FluentTranslationTest.cs | 349 ++++++++++++++++++ 8 files changed, 2386 insertions(+) create mode 100644 DeepL/FluentDocumentTranslation.cs create mode 100644 DeepL/FluentGlossary.cs create mode 100644 DeepL/FluentStyleRule.cs create mode 100644 DeepL/FluentTranslation.cs create mode 100644 DeepLTests/FluentDocumentTranslationTest.cs create mode 100644 DeepLTests/FluentGlossaryTest.cs create mode 100644 DeepLTests/FluentStyleRuleTest.cs create mode 100644 DeepLTests/FluentTranslationTest.cs diff --git a/DeepL/FluentDocumentTranslation.cs b/DeepL/FluentDocumentTranslation.cs new file mode 100644 index 0000000..e0957af --- /dev/null +++ b/DeepL/FluentDocumentTranslation.cs @@ -0,0 +1,296 @@ +// 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; + + 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. + 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). + public DocumentTranslationBuilder WithCancellation(CancellationToken cancellationToken) { + _cancellationToken = cancellationToken; + return this; + } + + /// + /// Uploads, waits, and downloads the translated document to . + /// Returns a awaitable result. + /// + public Task SaveTo(FileInfo outputFileInfo) { + if (outputFileInfo == null) throw new ArgumentNullException(nameof(outputFileInfo)); + EnsureTargetLanguage(); + return RunWithFileOutputAsync(outputFileInfo); + } + + /// + /// Uploads, waits, and downloads the translated document into . + /// + public Task SaveTo(Stream outputStream) { + if (outputStream == null) throw new ArgumentNullException(nameof(outputStream)); + EnsureTargetLanguage(); + return RunWithStreamOutputAsync(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 async Task RunWithFileOutputAsync(FileInfo outputFileInfo) { + if (_inputFileInfo != null) { + await _translator.TranslateDocumentAsync( + _inputFileInfo, + outputFileInfo, + _sourceLanguageCode, + _targetLanguageCode!, + _options, + _cancellationToken) + .ConfigureAwait(false); + return; + } + + using var outputFile = outputFileInfo.Open(FileMode.CreateNew, FileAccess.Write); + try { + await _translator.TranslateDocumentAsync( + _inputStream!, + _inputFileName!, + outputFile, + _sourceLanguageCode, + _targetLanguageCode!, + _options, + _cancellationToken) + .ConfigureAwait(false); + } catch { + try { outputFileInfo.Delete(); } catch { /* ignored */ } + throw; + } + } + + private Task RunWithStreamOutputAsync(Stream outputStream) { + if (_inputFileInfo != null) { + return RunFromFileToStreamAsync(outputStream); + } + + return _translator.TranslateDocumentAsync( + _inputStream!, + _inputFileName!, + outputStream, + _sourceLanguageCode, + _targetLanguageCode!, + _options, + _cancellationToken); + } + + private async Task RunFromFileToStreamAsync(Stream outputStream) { + using var inputFile = _inputFileInfo!.OpenRead(); + await _translator.TranslateDocumentAsync( + inputFile, + _inputFileInfo.Name, + outputStream, + _sourceLanguageCode, + _targetLanguageCode!, + _options, + _cancellationToken) + .ConfigureAwait(false); + } + + private void EnsureTargetLanguage() { + if (_targetLanguageCode == null) { + throw new InvalidOperationException( + "Target language is required. Call .To(targetLanguageCode) before uploading / saving."); + } + } + } + + /// 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. + public Task WaitUntilDoneAsync(CancellationToken cancellationToken = default) => + _translator.TranslateDocumentWaitUntilDoneAsync(Handle, 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..3ff9362 --- /dev/null +++ b/DeepL/FluentGlossary.cs @@ -0,0 +1,313 @@ +// 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 (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 (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..a98589a --- /dev/null +++ b/DeepL/FluentTranslation.cs @@ -0,0 +1,374 @@ +// 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 . + public TSelf WithStyle(string writingStyle) { + Options.WritingStyle = writingStyle ?? throw new ArgumentNullException(nameof(writingStyle)); + return Self; + } + + /// Sets the writing tone. Mutually exclusive with . + public TSelf WithTone(string writingTone) { + Options.WritingTone = writingTone ?? throw new ArgumentNullException(nameof(writingTone)); + return Self; + } + + /// Copies fields from the supplied options onto this builder. + public TSelf Using(TextRephraseOptions options) { + if (options == null) throw new ArgumentNullException(nameof(options)); + 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/DeepLTests/FluentDocumentTranslationTest.cs b/DeepLTests/FluentDocumentTranslationTest.cs new file mode 100644 index 0000000..2c6d44b --- /dev/null +++ b/DeepLTests/FluentDocumentTranslationTest.cs @@ -0,0 +1,284 @@ +// 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.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 = true, + }; + + 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); + Assert.True(captured.EnableDocumentMinification); + } + + [Fact] + public async Task WithCancellation_PassesToken() { + var translator = Substitute.For(); + using var input = new MemoryStream(); + using var output = new MemoryStream(); + using var cts = new CancellationTokenSource(); + + await translator.TranslateDocument(input, "in.docx").To("de").WithCancellation(cts.Token).SaveTo(output); + + await translator.Received(1).TranslateDocumentAsync( + input, + "in.docx", + output, + Arg.Any(), + "de", + Arg.Any(), + cts.Token); + } + + // ---------- 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"); }); + } + + // ---------- 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..fd0bfeb --- /dev/null +++ b/DeepLTests/FluentGlossaryTest.cs @@ -0,0 +1,269 @@ +// 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", ""); }); + } + } +} 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..4965884 --- /dev/null +++ b/DeepLTests/FluentTranslationTest.cs @@ -0,0 +1,349 @@ +// 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); + } + } +} From 53b10b1bec58d487311465141e65176a3a9db178 Mon Sep 17 00:00:00 2001 From: Tim Cadenbach Date: Fri, 24 Apr 2026 11:50:00 +0200 Subject: [PATCH 03/10] build: Modernize target frameworks to net8.0 + netstandard2.0 Drops dead runtimes and brings dependencies onto the .NET 8 LTS line. Library: TFMs: net5.0;netstandard2.0 -> netstandard2.0;net8.0 LangVersion: 8 -> 12 Microsoft.Extensions.Http.Polly: 5.0.1 -> 8.0.26 System.Text.Json: 5.0.2 -> 8.0.6 System.Net.Http.Json: (new) -> 8.0.1 Tests: TFMs: net5.0;netcoreapp3.1;net462 -> net8.0;net462 LangVersion: 8 -> 12 Microsoft.NET.Test.Sdk: 16.9.4 -> 17.11.1 xunit: 2.4.1 -> 2.9.2 xunit.runner.visualstudio: 2.4.3 -> 2.8.2 NSubstitute: 4.3.0 -> 5.3.0 coverlet.collector: 3.0.2 -> 6.0.2 JunitXml.TestLogger: 3.0.98 -> 5.0.0 Motivation: - net5.0 reached end of support in May 2022; netcoreapp3.1 in Dec 2022. The 5.x dependencies have since-patched CVEs that only ship in 8.0+. - netstandard2.0 is retained as the floor so the package stays usable from .NET Framework 4.7.2+, Mono, Unity, etc. - LangVersion 12 is purely additive on ns2.0 (no new BCL surface required). Co-Authored-By: Claude Opus 4.7 (1M context) --- DeepL/DeepL.csproj | 9 +++++---- DeepLTests/DeepLTests.csproj | 16 ++++++++-------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/DeepL/DeepL.csproj b/DeepL/DeepL.csproj index c6c8400..73945f9 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/DeepLTests/DeepLTests.csproj b/DeepLTests/DeepLTests.csproj index 9586d00..081cab7 100644 --- a/DeepLTests/DeepLTests.csproj +++ b/DeepLTests/DeepLTests.csproj @@ -1,23 +1,23 @@ - net5.0;netcoreapp3.1;net462 - 8 + net8.0;net462 + 12 true enable false - - - - - + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all From 4a0465f0f072b1e5943c8370db5397c1ece367b8 Mon Sep 17 00:00:00 2001 From: Tim Cadenbach Date: Fri, 24 Apr 2026 11:50:19 +0200 Subject: [PATCH 04/10] refactor: Adopt .NET 8 BCL APIs in HTTP/JSON internals Uses modern BCL surface on net8.0 while keeping the existing code as the netstandard2.0 fallback, guarded by #if NET8_0_OR_GREATER / NET5_0_OR_GREATER. Changes: - JsonUtils: use JsonNamingPolicy.SnakeCaseLower (net8 built-in) and HttpContent.ReadFromJsonAsync; fall back to the custom snake-case policy + stream reader on ns2.0. - DeepLHttpClient: * HttpMethod.Patch factored to a static field (net5+ uses the built-in HttpMethod.Patch, ns2.0 uses the string constructor once at init). * Extracted CreateJsonContent/CreateFormContent helpers; on net8 JSON bodies flow through JsonContent.Create (streams instead of string). * Inner handler on net8 is SocketsHttpHandler with PooledConnectionLifetime and PooledConnectionIdleTimeout set, so long-lived HttpClient instances pick up DNS changes correctly. * HTTP/2 preferred (RequestVersionOrHigher) on net8 for proper multiplexing on batch translation. - LargeFormUrlEncodedContent: the .NET 5 fix for the pre-.NET 5 size limit (dotnet/corefx#41686) means the built-in FormUrlEncodedContent now works correctly. The custom type is gated behind !NET5_0_OR_GREATER so it only compiles into the ns2.0 asset. No public API change. netstandard2.0 consumers see identical behavior; net8.0 consumers get the modern BCL paths and per-call allocation wins. Co-Authored-By: Claude Opus 4.7 (1M context) --- DeepL/Internal/DeepLHttpClient.cs | 89 +++++++++++++++----- DeepL/Internal/JsonUtils.cs | 57 +++++++------ DeepL/Internal/LargeFormUrlEncodedContent.cs | 11 ++- 3 files changed, 106 insertions(+), 51 deletions(-) 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 Date: Fri, 24 Apr 2026 11:50:38 +0200 Subject: [PATCH 05/10] feat: Add DeepL.Extensions.DependencyInjection companion package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New NuGet package (TFMs: netstandard2.0 + net8.0) providing Microsoft.Extensions.DependencyInjection integration for DeepL.net. Surface: services.AddDeepLClient(o => o.AuthKey = "..."); services.AddDeepLClient(builder.Configuration); services.AddDeepLClient(builder.Configuration.GetSection("...")); Behavior: - Registers DeepLClient as a singleton (documented thread-safe). - Forwards every surface interface (ITranslator, IWriter, IGlossaryManager, IStyleRuleManager, IVoiceManager) to the same singleton. - Routes the underlying HttpClient through IHttpClientFactory with the named client "DeepL", so consumers can layer their own handlers / resilience / logging on top without re-implementing the DeepL client. - Validates AuthKey via IValidateOptions — missing key surfaces on first resolve, not on first API call. - AddDeepLClient is idempotent (TryAdd semantics). Why a separate package (rather than adding DI to DeepL.net itself): - The main DeepL.net package stays dependency-free for consumers who use Autofac/DryIoc/SimpleInjector or construct DeepLClient manually. - Matches the established .NET ecosystem pattern (MediatR.Extensions.Microsoft.DependencyInjection, Polly.Extensions.Http, Serilog.Extensions.Hosting, OpenTelemetry.Extensions.Hosting). - DI-integration shape can evolve independently without forcing main-library version bumps. Versioning: lockstep with DeepL.net for simplicity until integration surface diverges. Strong-named with the shared sgKey.snk. Includes 15 DI-container tests covering registration, singleton lifetime, interface forwarding, auth-key validation, HttpClientFactory integration, ServerUrl propagation (verified with a capturing HttpMessageHandler), idempotency, and both configure-delegate + IConfiguration overloads. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...xtensions.DependencyInjection.Tests.csproj | 28 +++ .../DeepLServiceCollectionExtensionsTest.cs | 232 ++++++++++++++++++ ...eepL.Extensions.DependencyInjection.csproj | 45 ++++ .../DeepLOptions.cs | 40 +++ .../DeepLServiceCollectionExtensions.cs | 118 +++++++++ .../README.md | 79 ++++++ DeepL.net.sln | 51 ++++ 7 files changed, 593 insertions(+) create mode 100644 DeepL.Extensions.DependencyInjection.Tests/DeepL.Extensions.DependencyInjection.Tests.csproj create mode 100644 DeepL.Extensions.DependencyInjection.Tests/DeepLServiceCollectionExtensionsTest.cs create mode 100644 DeepL.Extensions.DependencyInjection/DeepL.Extensions.DependencyInjection.csproj create mode 100644 DeepL.Extensions.DependencyInjection/DeepLOptions.cs create mode 100644 DeepL.Extensions.DependencyInjection/DeepLServiceCollectionExtensions.cs create mode 100644 DeepL.Extensions.DependencyInjection/README.md 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..57994a8 --- /dev/null +++ b/DeepL.Extensions.DependencyInjection.Tests/DeepLServiceCollectionExtensionsTest.cs @@ -0,0 +1,232 @@ +// 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()); + 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(); + var voice = sp.GetRequiredService(); + + Assert.Same(client, translator); + Assert.Same(client, writer); + Assert.Same(client, glossary); + Assert.Same(client, styleRule); + Assert.Same(client, voice); + } + + [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..c02595a --- /dev/null +++ b/DeepL.Extensions.DependencyInjection/DeepLServiceCollectionExtensions.cs @@ -0,0 +1,118 @@ +// 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()); + 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..68b40fd --- /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`, `IVoiceManager` 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 From 4b88dc8d90ca25cf7056461b5aa5c0bdc2a4bb18 Mon Sep 17 00:00:00 2001 From: Tim Cadenbach Date: Fri, 24 Apr 2026 11:51:07 +0200 Subject: [PATCH 06/10] docs: Add /samples with FluentApi and DependencyInjection examples Two runnable sample console apps demonstrating idiomatic use of the new fluent API and the DeepL.Extensions.DependencyInjection package. Layout: samples/ DeepL.Samples.slnx (standalone solution, not in main CI scope) Directory.Build.props (shared net8.0 / LangVersion 12) README.md (usage + ASP.NET Core adaptation) FluentApi/ (every fluent entry point) DependencyInjection/ (generic host + AddDeepLClient + IHostedService consumer) The samples solution is deliberately separate from DeepL.net.sln so the library's CI build scope, NuGet pack, and signing behavior are unaffected. They reference the library via ProjectReference, so local changes to the library are picked up on each build. Both samples require DEEPL_AUTH_KEY to run, but compile without it. Co-Authored-By: Claude Opus 4.7 (1M context) --- samples/DeepL.Samples.slnx | 5 + .../DependencyInjection.csproj | 14 + samples/DependencyInjection/Program.cs | 41 +++ .../DependencyInjection/TranslationService.cs | 48 ++++ samples/Directory.Build.props | 17 ++ samples/FluentApi/FluentApi.csproj | 13 + samples/FluentApi/Program.cs | 265 ++++++++++++++++++ samples/README.md | 83 ++++++ 8 files changed, 486 insertions(+) create mode 100644 samples/DeepL.Samples.slnx create mode 100644 samples/DependencyInjection/DependencyInjection.csproj create mode 100644 samples/DependencyInjection/Program.cs create mode 100644 samples/DependencyInjection/TranslationService.cs create mode 100644 samples/Directory.Build.props create mode 100644 samples/FluentApi/FluentApi.csproj create mode 100644 samples/FluentApi/Program.cs create mode 100644 samples/README.md 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..c71026c --- /dev/null +++ b/samples/FluentApi/Program.cs @@ -0,0 +1,265 @@ +// 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}"); + + // 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. + await client.Document(handle).WaitUntilDoneAsync(); + 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..0b9b8a9 --- /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`, `IVoiceManager`): + +- `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`. From d7727505ce2b2e1734effc786203c6d8d4debf22 Mon Sep 17 00:00:00 2001 From: Tim Cadenbach Date: Fri, 24 Apr 2026 12:07:07 +0200 Subject: [PATCH 07/10] chore: Drop in-progress Voice API draft from this branch Reverts the changes introduced by 5330a21 ("Draft for a DeepL Voice implementation") so this branch no longer carries an unrelated WIP. Voice remains tracked on branch tc/add-voice and can be landed independently. Removed: - DeepL/IVoiceManager.cs, IVoiceSession.cs, VoiceSession.cs, VoiceSessionOptions.cs, VoiceMessageFormat.cs, TargetMediaVoice.cs, SourceMediaContentType.cs, SourceLanguageMode.cs - DeepL/Model/TargetMediaChunk.cs, TranscriptSegment.cs, TranscriptUpdate.cs, VoiceSessionInfo.cs, VoiceStreamError.cs - DeepLTests/VoiceSessionTest.cs - DeepL/DeepLClient.cs: using System.Net.WebSockets; IVoiceManager interface on DeepLClient; CreateVoiceSessionAsync method - DeepL/DeepL.csproj: System.Net.WebSockets.Client package reference Follow-up edits to keep this branch internally consistent: - DeepL.Extensions.DependencyInjection: drop the IVoiceManager forwarder; the DI package now forwards only ITranslator / IWriter / IGlossaryManager / IStyleRuleManager to the singleton. When Voice lands, re-adding the line is a one-line change. - DI package README + samples README: remove IVoiceManager from the interface list. - DI tests: drop the IVoiceManager assertions from the two "register-all" / "all-resolve-to-singleton" tests. Verified green: main solution builds on net8.0 + netstandard2.0 + net462, samples solution builds, Fluent tests 73/73 pass, DI tests 15/15 pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../DeepLServiceCollectionExtensionsTest.cs | 3 - .../DeepLServiceCollectionExtensions.cs | 3 +- .../README.md | 2 +- DeepL/DeepL.csproj | 1 - DeepL/DeepLClient.cs | 76 +----- DeepL/IVoiceManager.cs | 28 -- DeepL/IVoiceSession.cs | 77 ------ DeepL/Model/TargetMediaChunk.cs | 68 ----- DeepL/Model/TranscriptSegment.cs | 29 -- DeepL/Model/TranscriptUpdate.cs | 41 --- DeepL/Model/VoiceSessionInfo.cs | 40 --- DeepL/Model/VoiceStreamError.cs | 41 --- DeepL/SourceLanguageMode.cs | 29 -- DeepL/SourceMediaContentType.cs | 68 ----- DeepL/TargetMediaVoice.cs | 32 --- DeepL/VoiceMessageFormat.cs | 29 -- DeepL/VoiceSession.cs | 258 ------------------ DeepL/VoiceSessionOptions.cs | 70 ----- DeepLTests/VoiceSessionTest.cs | 180 ------------ samples/README.md | 2 +- 20 files changed, 4 insertions(+), 1073 deletions(-) delete mode 100644 DeepL/IVoiceManager.cs delete mode 100644 DeepL/IVoiceSession.cs delete mode 100644 DeepL/Model/TargetMediaChunk.cs delete mode 100644 DeepL/Model/TranscriptSegment.cs delete mode 100644 DeepL/Model/TranscriptUpdate.cs delete mode 100644 DeepL/Model/VoiceSessionInfo.cs delete mode 100644 DeepL/Model/VoiceStreamError.cs delete mode 100644 DeepL/SourceLanguageMode.cs delete mode 100644 DeepL/SourceMediaContentType.cs delete mode 100644 DeepL/TargetMediaVoice.cs delete mode 100644 DeepL/VoiceMessageFormat.cs delete mode 100644 DeepL/VoiceSession.cs delete mode 100644 DeepL/VoiceSessionOptions.cs delete mode 100644 DeepLTests/VoiceSessionTest.cs diff --git a/DeepL.Extensions.DependencyInjection.Tests/DeepLServiceCollectionExtensionsTest.cs b/DeepL.Extensions.DependencyInjection.Tests/DeepLServiceCollectionExtensionsTest.cs index 57994a8..702573b 100644 --- a/DeepL.Extensions.DependencyInjection.Tests/DeepLServiceCollectionExtensionsTest.cs +++ b/DeepL.Extensions.DependencyInjection.Tests/DeepLServiceCollectionExtensionsTest.cs @@ -45,7 +45,6 @@ public void AddDeepLClient_RegistersAllSurfaceInterfaces() { Assert.NotNull(sp.GetService()); Assert.NotNull(sp.GetService()); Assert.NotNull(sp.GetService()); - Assert.NotNull(sp.GetService()); } [Fact] @@ -57,13 +56,11 @@ public void AddDeepLClient_AllInterfacesResolveToSameSingleton() { var writer = sp.GetRequiredService(); var glossary = sp.GetRequiredService(); var styleRule = sp.GetRequiredService(); - var voice = sp.GetRequiredService(); Assert.Same(client, translator); Assert.Same(client, writer); Assert.Same(client, glossary); Assert.Same(client, styleRule); - Assert.Same(client, voice); } [Fact] diff --git a/DeepL.Extensions.DependencyInjection/DeepLServiceCollectionExtensions.cs b/DeepL.Extensions.DependencyInjection/DeepLServiceCollectionExtensions.cs index c02595a..7ff6b8f 100644 --- a/DeepL.Extensions.DependencyInjection/DeepLServiceCollectionExtensions.cs +++ b/DeepL.Extensions.DependencyInjection/DeepLServiceCollectionExtensions.cs @@ -34,7 +34,7 @@ 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 . @@ -112,7 +112,6 @@ private static void RegisterCore(IServiceCollection services) { 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 index 68b40fd..3db21b3 100644 --- a/DeepL.Extensions.DependencyInjection/README.md +++ b/DeepL.Extensions.DependencyInjection/README.md @@ -61,7 +61,7 @@ app.MapPost("/translate", async (ITranslator translator, string text, string tar ## What the registration does - Registers `DeepLClient` as a **singleton** (the client is documented thread-safe). -- Forwards `ITranslator`, `IWriter`, `IGlossaryManager`, `IStyleRuleManager`, `IVoiceManager` to the same singleton. +- 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 diff --git a/DeepL/DeepL.csproj b/DeepL/DeepL.csproj index 73945f9..6df9fa4 100644 --- a/DeepL/DeepL.csproj +++ b/DeepL/DeepL.csproj @@ -35,7 +35,6 @@ -
diff --git a/DeepL/DeepLClient.cs b/DeepL/DeepLClient.cs index 73cb4d4..6a2dc91 100644 --- a/DeepL/DeepLClient.cs +++ b/DeepL/DeepLClient.cs @@ -6,7 +6,6 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using System.Net.WebSockets; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading; @@ -55,7 +54,7 @@ Task RephraseTextAsync( /// Client for the DeepL API. To use the DeepL API, initialize an instance of this class using your DeepL /// Authentication Key. All functions are thread-safe, aside from . /// - public sealed class DeepLClient : Translator, IWriter, IGlossaryManager, IStyleRuleManager, IVoiceManager { + public sealed class DeepLClient : Translator, IWriter, IGlossaryManager, IStyleRuleManager { /// Initializes a new instance of the class. /// The message that describes the error. public DeepLClient(string authKey, DeepLClientOptions? options = null) : base(authKey, options) { } @@ -940,79 +939,6 @@ private static (string Key, string Value)[] CreateLanguageQueryParams( DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; - /// - public async Task CreateVoiceSessionAsync( - VoiceSessionOptions options, - CancellationToken cancellationToken = default) { - if (options == null) { - throw new ArgumentNullException(nameof(options)); - } - - if (options.TargetLanguages == null || options.TargetLanguages.Length == 0) { - throw new ArgumentException("At least one target language must be specified"); - } - - if (options.TargetLanguages.Length > 5) { - throw new ArgumentException("Maximum 5 target languages per session"); - } - - var requestData = new Dictionary { - ["source_media_content_type"] = options.SourceMediaContentType, - ["target_languages"] = options.TargetLanguages - }; - - if (options.MessageFormat != null) { - requestData["message_format"] = options.MessageFormat.Value.ToApiValue(); - } - - if (options.SourceLanguage != null) { - requestData["source_language"] = options.SourceLanguage; - } - - if (options.SourceLanguageMode != null) { - requestData["source_language_mode"] = options.SourceLanguageMode.Value.ToApiValue(); - } - - if (options.TargetMediaLanguages != null) { - requestData["target_media_languages"] = options.TargetMediaLanguages; - } - - if (options.TargetMediaContentType != null) { - requestData["target_media_content_type"] = options.TargetMediaContentType; - } - - if (options.TargetMediaVoice != null) { - requestData["target_media_voice"] = options.TargetMediaVoice.Value.ToApiValue(); - } - - if (options.GlossaryId != null) { - requestData["glossary_id"] = options.GlossaryId; - } - - if (options.Formality != null) { - requestData["formality"] = options.Formality; - } - - using var responseMessage = await _client - .ApiPostJsonAsync("v3/voice/realtime", cancellationToken, requestData, SerializationOptions) - .ConfigureAwait(false); - - await DeepLHttpClient.CheckStatusCodeAsync(responseMessage).ConfigureAwait(false); - var sessionInfo = await JsonUtils.DeserializeAsync(responseMessage).ConfigureAwait(false); - - // Establish WebSocket connection - var wsUri = new Uri($"{sessionInfo.StreamingUrl}?token={Uri.EscapeDataString(sessionInfo.Token)}"); - var webSocket = new ClientWebSocket(); - try { - await webSocket.ConnectAsync(wsUri, cancellationToken).ConfigureAwait(false); - } catch (Exception ex) { - webSocket.Dispose(); - throw new DeepLException("Failed to establish Voice API WebSocket connection", ex); - } - - return new VoiceSession(_client, webSocket, sessionInfo); - } - /// Class used for JSON-deserialization of style rule list results. private readonly struct StyleRuleListResult { /// Initializes a new instance of , used for JSON deserialization. diff --git a/DeepL/IVoiceManager.cs b/DeepL/IVoiceManager.cs deleted file mode 100644 index afc2e6f..0000000 --- a/DeepL/IVoiceManager.cs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright 2025 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; - -namespace DeepL { - /// Interface for creating Voice API streaming sessions. - public interface IVoiceManager : IDisposable { - /// - /// Creates a new Voice API streaming session for real-time speech transcription and translation. - /// This requests a session from the DeepL API and establishes a WebSocket connection. - /// - /// Options controlling session configuration including audio format, languages, etc. - /// The cancellation token to cancel the operation. - /// An for streaming audio and receiving transcripts. - /// If any option is invalid. - /// - /// If any error occurs while communicating with the DeepL API, a - /// or a derived class will be thrown. - /// - Task CreateVoiceSessionAsync( - VoiceSessionOptions options, - CancellationToken cancellationToken = default); - } -} diff --git a/DeepL/IVoiceSession.cs b/DeepL/IVoiceSession.cs deleted file mode 100644 index d5d0e6c..0000000 --- a/DeepL/IVoiceSession.cs +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright 2025 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.Model; - -namespace DeepL { - /// - /// Represents an active Voice API streaming session. Provides methods for sending audio data and receiving - /// real-time transcriptions and translations via events. - /// - /// - /// Events fire on a background thread. Consumers are responsible for marshaling to the appropriate - /// synchronization context if needed. Dispose the session to close the WebSocket connection. - /// - public interface IVoiceSession : IDisposable { - /// Raised when a source transcript update is received from the server. - event EventHandler? SourceTranscriptUpdated; - - /// Raised when a target transcript update is received from the server. - event EventHandler? TargetTranscriptUpdated; - - /// - /// Raised when a target media audio chunk is received from the server. This feature is in closed beta. - /// - event EventHandler? TargetMediaChunkReceived; - - /// Raised when an error message is received from the WebSocket connection. - event EventHandler? ErrorReceived; - - /// Raised when the end-of-stream message is received, indicating all outputs are complete. - event EventHandler? StreamEnded; - - /// The unique session identifier. - string? SessionId { get; } - - /// Whether the WebSocket connection is currently open. - bool IsConnected { get; } - - /// - /// Sends a chunk of audio data to the server. The audio encoding must match the - /// specified when creating the session. - /// - /// Audio data to send. Must not exceed 100 KB or 1 second duration. - /// The cancellation token to cancel the operation. - /// If the session is not connected or sending fails. - Task SendAudioAsync(byte[] audioData, CancellationToken cancellationToken = default); - - /// - /// Sends a chunk of audio data to the server using a memory-efficient overload. - /// - /// Audio data to send. Must not exceed 100 KB or 1 second duration. - /// The cancellation token to cancel the operation. - /// If the session is not connected or sending fails. - Task SendAudioAsync(ArraySegment audioData, CancellationToken cancellationToken = default); - - /// - /// Signals the end of the audio stream. Causes finalization of tentative transcript segments and - /// triggers emission of final transcript updates, end-of-transcript, and end-of-stream messages. - /// No more audio data can be sent after calling this method. - /// - /// The cancellation token to cancel the operation. - /// If the session is not connected or sending fails. - Task EndAudioAsync(CancellationToken cancellationToken = default); - - /// - /// Requests a reconnection token and establishes a new WebSocket connection, resuming the session. - /// This should be called when the WebSocket connection is lost unexpectedly. - /// - /// The cancellation token to cancel the operation. - /// If reconnection fails. - Task ReconnectAsync(CancellationToken cancellationToken = default); - } -} diff --git a/DeepL/Model/TargetMediaChunk.cs b/DeepL/Model/TargetMediaChunk.cs deleted file mode 100644 index f6b1522..0000000 --- a/DeepL/Model/TargetMediaChunk.cs +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright 2025 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.Text.Json.Serialization; - -namespace DeepL.Model { - /// - /// Represents a translated audio chunk from the Voice API. This feature is currently in closed beta. - /// Audio data is provided as an array of base64-encoded indivisible chunks. - /// - public sealed class TargetMediaChunk { - /// Initializes a new instance of . - /// The content type of the audio data. Present in the first message. - /// Number of header packets at the start of the data array, or null if all are audio. - /// Array of base64-encoded audio data packets. - /// Text corresponding to this audio chunk, for subtitle synchronization. - /// The target language of this audio chunk. - /// Duration of this audio chunk in seconds. - /// - /// The constructor for this class (and all other Model classes) should not be used by library users. Ideally it - /// would be marked , but needs to be for JSON deserialization. - /// In future this function may have backwards-incompatible changes. - /// - [JsonConstructor] - public TargetMediaChunk( - string? contentType, - int? headers, - string[] data, - string? text, - string? language, - double? duration) { - ContentType = contentType; - Headers = headers; - Data = data; - Text = text; - Language = language; - Duration = duration; - } - - /// The content type of the audio data. Present in the first message of a sequence. - [JsonPropertyName("content_type")] - public string? ContentType { get; } - - /// - /// Number of packets at the start of that contain initialization/header data. - /// Null or absent when all packets are audio data. - /// - [JsonPropertyName("headers")] - public int? Headers { get; } - - /// Array of base64-encoded indivisible audio data packets. - [JsonPropertyName("data")] - public string[] Data { get; } - - /// Text corresponding to this audio chunk, for subtitle synchronization. - [JsonPropertyName("text")] - public string? Text { get; } - - /// The target language of this audio chunk. - [JsonPropertyName("language")] - public string? Language { get; } - - /// Duration of this audio chunk in seconds. - [JsonPropertyName("duration")] - public double? Duration { get; } - } -} diff --git a/DeepL/Model/TranscriptSegment.cs b/DeepL/Model/TranscriptSegment.cs deleted file mode 100644 index b678ce2..0000000 --- a/DeepL/Model/TranscriptSegment.cs +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright 2025 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.Text.Json.Serialization; - -namespace DeepL.Model { - /// A single text segment within a Voice API transcript update. - public sealed class TranscriptSegment { - /// Initializes a new instance of . - /// The text content of this segment. - /// - /// The constructor for this class (and all other Model classes) should not be used by library users. Ideally it - /// would be marked , but needs to be for JSON deserialization. - /// In future this function may have backwards-incompatible changes. - /// - [JsonConstructor] - public TranscriptSegment(string text) { - Text = text; - } - - /// The text content of this segment. - [JsonPropertyName("text")] - public string Text { get; } - - /// Returns the text content of this segment. - public override string ToString() => Text; - } -} diff --git a/DeepL/Model/TranscriptUpdate.cs b/DeepL/Model/TranscriptUpdate.cs deleted file mode 100644 index 9db2adc..0000000 --- a/DeepL/Model/TranscriptUpdate.cs +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright 2025 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.Text.Json.Serialization; - -namespace DeepL.Model { - /// - /// Represents a transcript update from the Voice API, containing concluded (finalized) and tentative - /// (in-progress) text segments. Used for both source and target transcript updates. - /// - public sealed class TranscriptUpdate { - /// Initializes a new instance of . - /// Finalized text segments that will not change. - /// Preliminary text segments that may be refined. - /// The language code of this transcript update. Only present on target updates. - /// - /// The constructor for this class (and all other Model classes) should not be used by library users. Ideally it - /// would be marked , but needs to be for JSON deserialization. - /// In future this function may have backwards-incompatible changes. - /// - [JsonConstructor] - public TranscriptUpdate(TranscriptSegment[] concluded, TranscriptSegment[] tentative, string? language) { - Concluded = concluded; - Tentative = tentative; - Language = language; - } - - /// Finalized text segments that will not change. These segments are sent once and remain fixed. - [JsonPropertyName("concluded")] - public TranscriptSegment[] Concluded { get; } - - /// Preliminary text segments that may be refined as more audio context becomes available. - [JsonPropertyName("tentative")] - public TranscriptSegment[] Tentative { get; } - - /// The language code of this transcript update. Only present on target transcript updates. - [JsonPropertyName("language")] - public string? Language { get; } - } -} diff --git a/DeepL/Model/VoiceSessionInfo.cs b/DeepL/Model/VoiceSessionInfo.cs deleted file mode 100644 index 45aa899..0000000 --- a/DeepL/Model/VoiceSessionInfo.cs +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright 2025 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.Text.Json.Serialization; - -namespace DeepL.Model { - /// Information about a Voice API session, received from the session request endpoint. - public sealed class VoiceSessionInfo { - /// Initializes a new instance of . - /// The WebSocket URL for establishing the stream connection. - /// Ephemeral authentication token for the streaming endpoint. - /// Unique identifier for the session. - /// - /// The constructor for this class (and all other Model classes) should not be used by library users. Ideally it - /// would be marked , but needs to be for JSON deserialization. - /// In future this function may have backwards-incompatible changes. - /// - [JsonConstructor] - public VoiceSessionInfo(string streamingUrl, string token, string? sessionId) { - StreamingUrl = streamingUrl; - Token = token; - SessionId = sessionId; - } - - /// The WebSocket URL to use for establishing the stream connection. - [JsonPropertyName("streaming_url")] - public string StreamingUrl { get; } - - /// - /// Ephemeral authentication token for the streaming endpoint. Valid for one-time use only. - /// - [JsonPropertyName("token")] - public string Token { get; } - - /// Unique identifier for the session. - [JsonPropertyName("session_id")] - public string? SessionId { get; } - } -} diff --git a/DeepL/Model/VoiceStreamError.cs b/DeepL/Model/VoiceStreamError.cs deleted file mode 100644 index 80a0311..0000000 --- a/DeepL/Model/VoiceStreamError.cs +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright 2025 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.Text.Json.Serialization; - -namespace DeepL.Model { - /// Represents an error message received from the Voice API WebSocket connection. - public sealed class VoiceStreamError { - /// Initializes a new instance of . - /// The error code. - /// The reason code for the error. - /// A human-readable error message. - /// - /// The constructor for this class (and all other Model classes) should not be used by library users. Ideally it - /// would be marked , but needs to be for JSON deserialization. - /// In future this function may have backwards-incompatible changes. - /// - [JsonConstructor] - public VoiceStreamError(string? code, string? reason, string? message) { - Code = code; - Reason = reason; - Message = message; - } - - /// The error code. - [JsonPropertyName("code")] - public string? Code { get; } - - /// The reason code for the error. - [JsonPropertyName("reason")] - public string? Reason { get; } - - /// A human-readable error message. - [JsonPropertyName("message")] - public string? Message { get; } - - /// Returns the error message. - public override string ToString() => $"VoiceStreamError(code={Code}, reason={Reason}, message={Message})"; - } -} diff --git a/DeepL/SourceLanguageMode.cs b/DeepL/SourceLanguageMode.cs deleted file mode 100644 index 521037f..0000000 --- a/DeepL/SourceLanguageMode.cs +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright 2025 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; - -namespace DeepL { - /// Controls how the source language value is used in Voice API sessions. - public enum SourceLanguageMode { - /// Treats source language as a hint; server can override. - Auto, - - /// Treats source language as mandatory; server must use this language. - Fixed - } - - /// Extension methods for . - public static class SourceLanguageModeExtensions { - /// Retrieves the string representation used by the DeepL API. - /// If an unknown enum value is passed. - public static string ToApiValue(this SourceLanguageMode mode) { - return mode switch { - SourceLanguageMode.Auto => "auto", - SourceLanguageMode.Fixed => "fixed", - _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unrecognized source language mode value") - }; - } - } -} diff --git a/DeepL/SourceMediaContentType.cs b/DeepL/SourceMediaContentType.cs deleted file mode 100644 index fe48105..0000000 --- a/DeepL/SourceMediaContentType.cs +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright 2025 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 { - /// - /// String constants for audio format content types supported by the DeepL Voice API. - /// Use these when configuring . - /// - public static class SourceMediaContentType { - /// Auto-detect container and codec. Supported for all formats except PCM. - public const string Auto = "audio/auto"; - - /// FLAC container with FLAC codec. - public const string Flac = "audio/flac"; - - /// MPEG container with MP3 codec. - public const string Mpeg = "audio/mpeg"; - - /// Ogg container with auto-detected codec (FLAC or OPUS). - public const string Ogg = "audio/ogg"; - - /// WebM container with OPUS codec. - public const string WebM = "audio/webm"; - - /// Matroska container with auto-detected codec. - public const string Matroska = "audio/x-matroska"; - - /// Ogg container with FLAC codec. - public const string OggFlac = "audio/ogg;codecs=flac"; - - /// Ogg container with OPUS codec. - public const string OggOpus = "audio/ogg;codecs=opus"; - - /// PCM signed 16-bit little-endian at 8000 Hz. - public const string PcmS16le8000 = "audio/pcm;encoding=s16le;rate=8000"; - - /// PCM signed 16-bit little-endian at 16000 Hz. Recommended for general use. - public const string PcmS16le16000 = "audio/pcm;encoding=s16le;rate=16000"; - - /// PCM signed 16-bit little-endian at 44100 Hz. - public const string PcmS16le44100 = "audio/pcm;encoding=s16le;rate=44100"; - - /// PCM signed 16-bit little-endian at 48000 Hz. - public const string PcmS16le48000 = "audio/pcm;encoding=s16le;rate=48000"; - - /// PCM A-Law at 8000 Hz (G.711). - public const string PcmAlaw8000 = "audio/pcm;encoding=alaw;rate=8000"; - - /// PCM µ-Law at 8000 Hz (G.711). - public const string PcmUlaw8000 = "audio/pcm;encoding=ulaw;rate=8000"; - - /// WebM container with OPUS codec (explicit). - public const string WebMOpus = "audio/webm;codecs=opus"; - - /// Matroska container with AAC codec. - public const string MatroskaAac = "audio/x-matroska;codecs=aac"; - - /// Matroska container with FLAC codec. - public const string MatroskaFlac = "audio/x-matroska;codecs=flac"; - - /// Matroska container with MP3 codec. - public const string MatroskaMp3 = "audio/x-matroska;codecs=mp3"; - - /// Matroska container with OPUS codec. - public const string MatroskaOpus = "audio/x-matroska;codecs=opus"; - } -} diff --git a/DeepL/TargetMediaVoice.cs b/DeepL/TargetMediaVoice.cs deleted file mode 100644 index 10b5c33..0000000 --- a/DeepL/TargetMediaVoice.cs +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright 2025 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; - -namespace DeepL { - /// - /// Target audio voice selection for synthesized speech in Voice API sessions. - /// This feature is currently in closed beta. - /// - public enum TargetMediaVoice { - /// Male voice. - Male, - - /// Female voice. - Female - } - - /// Extension methods for . - public static class TargetMediaVoiceExtensions { - /// Retrieves the string representation used by the DeepL API. - /// If an unknown enum value is passed. - public static string ToApiValue(this TargetMediaVoice voice) { - return voice switch { - TargetMediaVoice.Male => "male", - TargetMediaVoice.Female => "female", - _ => throw new ArgumentOutOfRangeException(nameof(voice), voice, "Unrecognized target media voice value") - }; - } - } -} diff --git a/DeepL/VoiceMessageFormat.cs b/DeepL/VoiceMessageFormat.cs deleted file mode 100644 index d4aace6..0000000 --- a/DeepL/VoiceMessageFormat.cs +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright 2025 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; - -namespace DeepL { - /// Message encoding format for Voice API WebSocket communication. - public enum VoiceMessageFormat { - /// JSON-encoded messages sent as TEXT WebSocket frames. Binary fields are base64-encoded. - Json, - - /// MessagePack-encoded messages sent as BINARY WebSocket frames. Binary fields are raw binary. - MessagePack - } - - /// Extension methods for . - public static class VoiceMessageFormatExtensions { - /// Retrieves the string representation used by the DeepL API. - /// If an unknown enum value is passed. - public static string ToApiValue(this VoiceMessageFormat format) { - return format switch { - VoiceMessageFormat.Json => "json", - VoiceMessageFormat.MessagePack => "msgpack", - _ => throw new ArgumentOutOfRangeException(nameof(format), format, "Unrecognized message format value") - }; - } - } -} diff --git a/DeepL/VoiceSession.cs b/DeepL/VoiceSession.cs deleted file mode 100644 index 0ff826f..0000000 --- a/DeepL/VoiceSession.cs +++ /dev/null @@ -1,258 +0,0 @@ -// Copyright 2025 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.WebSockets; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using DeepL.Internal; -using DeepL.Model; - -namespace DeepL { - /// - /// Internal implementation of that manages a WebSocket connection - /// to the DeepL Voice API for real-time speech transcription and translation. - /// - internal sealed class VoiceSession : IVoiceSession { - private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - }; - - private readonly DeepLHttpClient _httpClient; - private readonly object _lock = new object(); - private ClientWebSocket _webSocket; - private CancellationTokenSource _receiveCts; - private Task? _receiveTask; - private string _lastToken; - private bool _disposed; - - /// - public event EventHandler? SourceTranscriptUpdated; - - /// - public event EventHandler? TargetTranscriptUpdated; - - /// - public event EventHandler? TargetMediaChunkReceived; - - /// - public event EventHandler? ErrorReceived; - - /// - public event EventHandler? StreamEnded; - - /// - public string? SessionId { get; private set; } - - /// - public bool IsConnected { - get { - lock (_lock) { - return !_disposed && _webSocket.State == WebSocketState.Open; - } - } - } - - internal VoiceSession( - DeepLHttpClient httpClient, - ClientWebSocket webSocket, - VoiceSessionInfo sessionInfo) { - _httpClient = httpClient; - _webSocket = webSocket; - _lastToken = sessionInfo.Token; - SessionId = sessionInfo.SessionId; - _receiveCts = new CancellationTokenSource(); - _receiveTask = Task.Run(() => ReceiveLoopAsync(_receiveCts.Token)); - } - - /// - public async Task SendAudioAsync(byte[] audioData, CancellationToken cancellationToken = default) { - await SendAudioAsync(new ArraySegment(audioData), cancellationToken).ConfigureAwait(false); - } - - /// - public async Task SendAudioAsync(ArraySegment audioData, CancellationToken cancellationToken = default) { - EnsureConnected(); - - var base64Data = Convert.ToBase64String( - audioData.Array ?? throw new ArgumentException("Audio data array is null"), - audioData.Offset, - audioData.Count); - var message = $"{{\"source_media_chunk\":{{\"data\":\"{base64Data}\"}}}}"; - var bytes = Encoding.UTF8.GetBytes(message); - - await _webSocket.SendAsync( - new ArraySegment(bytes), - WebSocketMessageType.Text, - endOfMessage: true, - cancellationToken).ConfigureAwait(false); - } - - /// - public async Task EndAudioAsync(CancellationToken cancellationToken = default) { - EnsureConnected(); - - var message = "{\"end_of_source_media\":{}}"; - var bytes = Encoding.UTF8.GetBytes(message); - - await _webSocket.SendAsync( - new ArraySegment(bytes), - WebSocketMessageType.Text, - endOfMessage: true, - cancellationToken).ConfigureAwait(false); - } - - /// - public async Task ReconnectAsync(CancellationToken cancellationToken = default) { - // Stop current receive loop - _receiveCts.Cancel(); - if (_receiveTask != null) { - try { - await _receiveTask.ConfigureAwait(false); - } catch (OperationCanceledException) { - // Expected - } - } - - // Close existing WebSocket if still open - if (_webSocket.State == WebSocketState.Open || _webSocket.State == WebSocketState.CloseReceived) { - try { - await _webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Reconnecting", CancellationToken.None) - .ConfigureAwait(false); - } catch (WebSocketException) { - // Ignore close errors during reconnection - } - } - - _webSocket.Dispose(); - - // Request new token via GET v3/voice/realtime?token= - var queryParams = new[] { ("token", _lastToken) }; - using var responseMessage = await _httpClient.ApiGetAsync("v3/voice/realtime", cancellationToken, queryParams) - .ConfigureAwait(false); - await DeepLHttpClient.CheckStatusCodeAsync(responseMessage).ConfigureAwait(false); - var sessionInfo = await JsonUtils.DeserializeAsync(responseMessage).ConfigureAwait(false); - - _lastToken = sessionInfo.Token; - SessionId = sessionInfo.SessionId; - - // Establish new WebSocket connection - var wsUri = new Uri($"{sessionInfo.StreamingUrl}?token={Uri.EscapeDataString(sessionInfo.Token)}"); - _webSocket = new ClientWebSocket(); - await _webSocket.ConnectAsync(wsUri, cancellationToken).ConfigureAwait(false); - - // Restart receive loop - _receiveCts = new CancellationTokenSource(); - _receiveTask = Task.Run(() => ReceiveLoopAsync(_receiveCts.Token)); - } - - /// Background loop that receives and dispatches WebSocket messages. - private async Task ReceiveLoopAsync(CancellationToken cancellationToken) { - var buffer = new byte[64 * 1024]; // 64 KB buffer - var messageBuilder = new StringBuilder(); - - try { - while (!cancellationToken.IsCancellationRequested && - _webSocket.State == WebSocketState.Open) { - messageBuilder.Clear(); - WebSocketReceiveResult result; - do { - result = await _webSocket.ReceiveAsync( - new ArraySegment(buffer), cancellationToken).ConfigureAwait(false); - - if (result.MessageType == WebSocketMessageType.Close) { - return; - } - - if (result.MessageType == WebSocketMessageType.Text) { - messageBuilder.Append(Encoding.UTF8.GetString(buffer, 0, result.Count)); - } - } while (!result.EndOfMessage); - - if (messageBuilder.Length > 0) { - DispatchMessage(messageBuilder.ToString()); - } - } - } catch (OperationCanceledException) { - // Normal cancellation - } catch (WebSocketException) { - // Connection lost — consumer should call ReconnectAsync - } - } - - /// Parses a JSON message from the WebSocket and dispatches it to the appropriate event. - private void DispatchMessage(string json) { - try { - using var document = JsonDocument.Parse(json); - var root = document.RootElement; - - if (root.TryGetProperty("source_transcript_update", out var sourceUpdate)) { - var update = JsonSerializer.Deserialize(sourceUpdate.GetRawText(), JsonOptions); - if (update != null) { - SourceTranscriptUpdated?.Invoke(this, update); - } - } else if (root.TryGetProperty("target_transcript_update", out var targetUpdate)) { - var update = JsonSerializer.Deserialize(targetUpdate.GetRawText(), JsonOptions); - if (update != null) { - TargetTranscriptUpdated?.Invoke(this, update); - } - } else if (root.TryGetProperty("target_media_chunk", out var mediaChunk)) { - var chunk = JsonSerializer.Deserialize(mediaChunk.GetRawText(), JsonOptions); - if (chunk != null) { - TargetMediaChunkReceived?.Invoke(this, chunk); - } - } else if (root.TryGetProperty("end_of_source_transcript", out _)) { - // Source transcript complete — no special event needed, handled via StreamEnded - } else if (root.TryGetProperty("end_of_target_transcript", out _)) { - // Target transcript complete — no special event needed, handled via StreamEnded - } else if (root.TryGetProperty("end_of_target_media", out _)) { - // Target media complete — no special event needed, handled via StreamEnded - } else if (root.TryGetProperty("end_of_stream", out _)) { - StreamEnded?.Invoke(this, EventArgs.Empty); - } else if (root.TryGetProperty("error", out var errorElement)) { - var error = JsonSerializer.Deserialize(errorElement.GetRawText(), JsonOptions); - if (error != null) { - ErrorReceived?.Invoke(this, error); - } - } - } catch (JsonException) { - // Ignore malformed messages - } - } - - private void EnsureConnected() { - if (_disposed) { - throw new ObjectDisposedException(nameof(VoiceSession)); - } - - if (_webSocket.State != WebSocketState.Open) { - throw new DeepLException("Voice session WebSocket is not connected"); - } - } - - /// Releases the WebSocket connection and stops the receive loop. - public void Dispose() { - lock (_lock) { - if (_disposed) return; - _disposed = true; - } - - _receiveCts.Cancel(); - - try { - if (_webSocket.State == WebSocketState.Open) { - _webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Disposing", CancellationToken.None) - .GetAwaiter().GetResult(); - } - } catch (WebSocketException) { - // Ignore errors during disposal - } - - _webSocket.Dispose(); - _receiveCts.Dispose(); - } - } -} diff --git a/DeepL/VoiceSessionOptions.cs b/DeepL/VoiceSessionOptions.cs deleted file mode 100644 index cf1235c..0000000 --- a/DeepL/VoiceSessionOptions.cs +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright 2025 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 { - /// - /// Options to control Voice API session creation. These options are provided to - /// . - /// - public sealed class VoiceSessionOptions { - /// Initializes a new object. - public VoiceSessionOptions() { } - - /// - /// The audio format for streaming, which specifies container, codec, and encoding parameters. - /// Use constants from for supported values. Required. - /// - public string SourceMediaContentType { get; set; } = DeepL.SourceMediaContentType.Auto; - - /// - /// Message encoding format for WebSocket communication. Defaults to . - /// - public VoiceMessageFormat? MessageFormat { get; set; } - - /// - /// The source language of the audio stream, or null for auto-detection. - /// Must be a supported Voice API source language complying with IETF BCP 47 language tags. - /// - public string? SourceLanguage { get; set; } - - /// - /// Controls how the value is used. - /// Defaults to if not specified. - /// - public SourceLanguageMode? SourceLanguageMode { get; set; } - - /// - /// List of target languages for translation. The stream will emit translations for each language. - /// Maximum 5 target languages per session. Language identifiers must comply with IETF BCP 47. - /// - public string[] TargetLanguages { get; set; } = System.Array.Empty(); - - /// - /// List of target languages for which to generate synthesized audio. This feature is in closed beta. - /// Languages specified here will automatically be added to if not already present. - /// Maximum 5 target media languages per session. - /// - public string[]? TargetMediaLanguages { get; set; } - - /// - /// The audio format for synthesized target media streaming. This feature is in closed beta. - /// Defaults to "audio/webm;codecs=opus" if not specified. - /// - public string? TargetMediaContentType { get; set; } - - /// - /// Target audio voice selection for synthesized speech. This feature is in closed beta. - /// - public TargetMediaVoice? TargetMediaVoice { get; set; } - - /// A glossary ID to use for translation. - public string? GlossaryId { get; set; } - - /// - /// Sets whether the translated text should lean towards formal or informal language. - /// Possible values: "default", "formal", "more", "informal", "less". - /// - public string? Formality { get; set; } - } -} diff --git a/DeepLTests/VoiceSessionTest.cs b/DeepLTests/VoiceSessionTest.cs deleted file mode 100644 index 6f494a4..0000000 --- a/DeepLTests/VoiceSessionTest.cs +++ /dev/null @@ -1,180 +0,0 @@ -// Copyright 2025 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.Text.Json; -using System.Threading.Tasks; -using DeepL; -using DeepL.Model; -using Xunit; - -namespace DeepLTests { - /// Unit tests for Voice API types that do not require API access. - public sealed class VoiceSessionUnitTest { - [Fact] - public void TestVoiceSessionOptionsDefaults() { - var options = new VoiceSessionOptions(); - Assert.Equal(SourceMediaContentType.Auto, options.SourceMediaContentType); - Assert.Null(options.MessageFormat); - Assert.Null(options.SourceLanguage); - Assert.Null(options.SourceLanguageMode); - Assert.NotNull(options.TargetLanguages); - Assert.Empty(options.TargetLanguages); - Assert.Null(options.TargetMediaLanguages); - Assert.Null(options.TargetMediaContentType); - Assert.Null(options.TargetMediaVoice); - Assert.Null(options.GlossaryId); - Assert.Null(options.Formality); - } - - [Fact] - public void TestVoiceSessionOptionsConfiguration() { - var options = new VoiceSessionOptions { - SourceMediaContentType = SourceMediaContentType.OggOpus, - MessageFormat = VoiceMessageFormat.Json, - SourceLanguage = "en", - SourceLanguageMode = DeepL.SourceLanguageMode.Fixed, - TargetLanguages = new[] { "de", "fr", "es" }, - TargetMediaVoice = TargetMediaVoice.Female, - GlossaryId = "test-glossary-id", - Formality = "formal" - }; - - Assert.Equal(SourceMediaContentType.OggOpus, options.SourceMediaContentType); - Assert.Equal(VoiceMessageFormat.Json, options.MessageFormat); - Assert.Equal("en", options.SourceLanguage); - Assert.Equal(DeepL.SourceLanguageMode.Fixed, options.SourceLanguageMode); - Assert.Equal(3, options.TargetLanguages.Length); - Assert.Equal(TargetMediaVoice.Female, options.TargetMediaVoice); - Assert.Equal("test-glossary-id", options.GlossaryId); - Assert.Equal("formal", options.Formality); - } - - [Fact] - public void TestVoiceMessageFormatApiValues() { - Assert.Equal("json", VoiceMessageFormat.Json.ToApiValue()); - Assert.Equal("msgpack", VoiceMessageFormat.MessagePack.ToApiValue()); - } - - [Fact] - public void TestSourceLanguageModeApiValues() { - Assert.Equal("auto", DeepL.SourceLanguageMode.Auto.ToApiValue()); - Assert.Equal("fixed", DeepL.SourceLanguageMode.Fixed.ToApiValue()); - } - - [Fact] - public void TestTargetMediaVoiceApiValues() { - Assert.Equal("male", TargetMediaVoice.Male.ToApiValue()); - Assert.Equal("female", TargetMediaVoice.Female.ToApiValue()); - } - - [Fact] - public void TestVoiceSessionInfoDeserialization() { - var json = "{\"streaming_url\":\"wss://api.deepl.com/v3/voice/realtime/connect\"," + - "\"token\":\"test-token-123\"," + - "\"session_id\":\"test-session-456\"}"; - var info = JsonSerializer.Deserialize(json); - Assert.NotNull(info); - Assert.Equal("wss://api.deepl.com/v3/voice/realtime/connect", info!.StreamingUrl); - Assert.Equal("test-token-123", info.Token); - Assert.Equal("test-session-456", info.SessionId); - } - - [Fact] - public void TestTranscriptUpdateDeserialization() { - var json = "{\"concluded\":[{\"text\":\"Hello \"}],\"tentative\":[{\"text\":\"world\"}],\"language\":\"de\"}"; - var update = JsonSerializer.Deserialize(json); - Assert.NotNull(update); - Assert.Single(update!.Concluded); - Assert.Equal("Hello ", update.Concluded[0].Text); - Assert.Single(update.Tentative); - Assert.Equal("world", update.Tentative[0].Text); - Assert.Equal("de", update.Language); - } - - [Fact] - public void TestTranscriptSegmentDeserialization() { - var json = "{\"text\":\"Hello world\"}"; - var segment = JsonSerializer.Deserialize(json); - Assert.NotNull(segment); - Assert.Equal("Hello world", segment!.Text); - Assert.Equal("Hello world", segment.ToString()); - } - - [Fact] - public void TestTargetMediaChunkDeserialization() { - var json = "{\"content_type\":\"audio/webm;codecs=opus\"," + - "\"headers\":1," + - "\"data\":[\"base64data1\",\"base64data2\"]," + - "\"text\":\"Hallo Welt\"," + - "\"language\":\"de\"," + - "\"duration\":1.5}"; - var chunk = JsonSerializer.Deserialize(json); - Assert.NotNull(chunk); - Assert.Equal("audio/webm;codecs=opus", chunk!.ContentType); - Assert.Equal(1, chunk.Headers); - Assert.Equal(2, chunk.Data.Length); - Assert.Equal("base64data1", chunk.Data[0]); - Assert.Equal("Hallo Welt", chunk.Text); - Assert.Equal("de", chunk.Language); - Assert.Equal(1.5, chunk.Duration); - } - - [Fact] - public void TestVoiceStreamErrorDeserialization() { - var json = "{\"code\":\"4001\",\"reason\":\"invalid_audio\",\"message\":\"Audio format not supported\"}"; - var error = JsonSerializer.Deserialize(json); - Assert.NotNull(error); - Assert.Equal("4001", error!.Code); - Assert.Equal("invalid_audio", error.Reason); - Assert.Equal("Audio format not supported", error.Message); - } - - [Fact] - public void TestSourceMediaContentTypeConstants() { - Assert.Equal("audio/auto", SourceMediaContentType.Auto); - Assert.Equal("audio/flac", SourceMediaContentType.Flac); - Assert.Equal("audio/mpeg", SourceMediaContentType.Mpeg); - Assert.Equal("audio/ogg", SourceMediaContentType.Ogg); - Assert.Equal("audio/webm", SourceMediaContentType.WebM); - Assert.Equal("audio/x-matroska", SourceMediaContentType.Matroska); - Assert.Equal("audio/ogg;codecs=flac", SourceMediaContentType.OggFlac); - Assert.Equal("audio/ogg;codecs=opus", SourceMediaContentType.OggOpus); - Assert.Equal("audio/pcm;encoding=s16le;rate=16000", SourceMediaContentType.PcmS16le16000); - Assert.Equal("audio/webm;codecs=opus", SourceMediaContentType.WebMOpus); - } - } - - /// Tests for Voice API session creation that require API access. - public sealed class VoiceSessionClientTest : BaseDeepLTest { - [Fact] - public async Task TestCreateSessionRequiresTargetLanguages() { - var client = CreateTestClient(); - var options = new VoiceSessionOptions { - SourceMediaContentType = SourceMediaContentType.OggOpus - }; - await Assert.ThrowsAsync( - () => client.CreateVoiceSessionAsync(options)); - } - - [Fact] - public async Task TestCreateSessionRejectsExcessiveTargetLanguages() { - var client = CreateTestClient(); - var options = new VoiceSessionOptions { - SourceMediaContentType = SourceMediaContentType.OggOpus, - TargetLanguages = new[] { "de", "fr", "es", "it", "nl", "pt" } - }; - await Assert.ThrowsAsync( - () => client.CreateVoiceSessionAsync(options)); - } - - [Fact] - public async Task TestCreateSessionRejectsNullOptions() { - var client = CreateTestClient(); - await Assert.ThrowsAsync( - () => client.CreateVoiceSessionAsync(null!)); - } - } -} diff --git a/samples/README.md b/samples/README.md index 0b9b8a9..c63e9dd 100644 --- a/samples/README.md +++ b/samples/README.md @@ -40,7 +40,7 @@ The sample creates temporary glossaries, style rules, and files, and cleans them ### 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`, `IVoiceManager`): +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 From a3247f29914e5af73d548dc7f7c8c89efb2d7165 Mon Sep 17 00:00:00 2001 From: Tim Cadenbach Date: Fri, 24 Apr 2026 12:32:24 +0200 Subject: [PATCH 08/10] feat: Progress reporting + fluent-style cancellation for document translation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ergonomic additions to the fluent document-translation layer that address the async-poll nature of DeepL's document API. IProgress: - New DocumentTranslationBuilder.WithProgress(IProgress) method. Reports each status tick during the wait phase (between upload and download), useful for UI progress indicators, structured logging, or webhook emissions. - New DocumentRef.WaitUntilDoneAsync(IProgress, CancellationToken) overload so the split upload/poll/download flow can also use progress. - When progress is configured the builder runs upload → poll-with-callbacks → download in the fluent layer rather than delegating to the library's one-shot TranslateDocumentAsync (which has no progress hook). Without progress the existing delegation path is preserved, so DocumentTranslationException wrapping and minification semantics stay identical to the non-fluent API. DocumentTranslationJob: - SaveTo(FileInfo) and SaveTo(Stream) now return DocumentTranslationJob, a lightweight wrapper around Task that also exposes Cancel(). The job is directly awaitable (GetAwaiter) and implicitly converts to Task, so every existing call site (await builder.SaveTo(...)) compiles and behaves unchanged. - Internally each SaveTo call creates a CancellationTokenSource linked to any token provided via WithCancellation, so job.Cancel() propagates cancellation into the library even when the caller supplied their own token. The linked CTS is disposed in a continuation once the job completes. - Lets callers keep the fluent style end-to-end without pre-building a CancellationTokenSource just to have a cancel handle: var job = translator.TranslateDocument(file).To("de").SaveTo(out); // ...later... job.Cancel(); await job; // throws OperationCanceledException Tests (7 new, 80 total Fluent tests passing on net8.0 + net462): - SaveTo_ReturnsAwaitableJob — job is awaitable + implicitly convertible to Task - Job_Cancel_PropagatesThroughLinkedToken — cancel propagates into library call - Job_Cancel_AfterCompletion_IsNoOp — safe post-completion - WithCancellation_ExternalCancelPropagatesThroughLinkedToken — external cts cancellation also propagates via the linked token - WithProgress_ReportsStatusDuringPolling — each poll tick surfaces to progress - WithProgress_ErrorStatus_ThrowsDeepLException — error-status path - WithProgress_NullProgress_Throws — null guard - DocumentRef_WaitUntilDoneAsync_WithProgress_ReportsTicks — split-flow progress Samples: - samples/FluentApi/Program.cs demonstrates both new features end-to-end: WithProgress callback printing each status tick, and SaveTo-returns-Job with a mid-flight job.Cancel() triggered from a background task. Co-Authored-By: Claude Opus 4.7 (1M context) --- DeepL/FluentDocumentTranslation.cs | 212 ++++++++++++++++-- DeepLTests/FluentDocumentTranslationTest.cs | 224 +++++++++++++++++++- samples/FluentApi/Program.cs | 39 +++- 3 files changed, 448 insertions(+), 27 deletions(-) diff --git a/DeepL/FluentDocumentTranslation.cs b/DeepL/FluentDocumentTranslation.cs index e0957af..254bd3d 100644 --- a/DeepL/FluentDocumentTranslation.cs +++ b/DeepL/FluentDocumentTranslation.cs @@ -72,6 +72,7 @@ public sealed class DocumentTranslationBuilder { private string? _sourceLanguageCode; private string? _targetLanguageCode; private CancellationToken _cancellationToken; + private IProgress? _progress; internal DocumentTranslationBuilder(ITranslator translator, FileInfo inputFileInfo) { _translator = translator; @@ -151,29 +152,55 @@ public DocumentTranslationBuilder WithOutputFormat(string outputFormat) { return this; } - /// Associates a cancellation token with the eventual request(s). + /// + /// 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 awaitable result. + /// Returns a that is directly awaitable AND supports + /// for fluent, ad-hoc cancellation. /// - public Task SaveTo(FileInfo outputFileInfo) { + public DocumentTranslationJob SaveTo(FileInfo outputFileInfo) { if (outputFileInfo == null) throw new ArgumentNullException(nameof(outputFileInfo)); EnsureTargetLanguage(); - return RunWithFileOutputAsync(outputFileInfo); + return Start(outputFileInfo, outputStream: null); } /// /// Uploads, waits, and downloads the translated document into . + /// Returns a that is directly awaitable AND supports + /// . /// - public Task SaveTo(Stream outputStream) { + public DocumentTranslationJob SaveTo(Stream outputStream) { if (outputStream == null) throw new ArgumentNullException(nameof(outputStream)); EnsureTargetLanguage(); - return RunWithStreamOutputAsync(outputStream); + return Start(outputFile: null, outputStream); } /// @@ -196,7 +223,31 @@ public Task UploadAsync() { _cancellationToken); } - private async Task RunWithFileOutputAsync(FileInfo outputFileInfo) { + private DocumentTranslationJob Start(FileInfo? outputFile, Stream? outputStream) { + 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, @@ -204,7 +255,7 @@ await _translator.TranslateDocumentAsync( _sourceLanguageCode, _targetLanguageCode!, _options, - _cancellationToken) + ct) .ConfigureAwait(false); return; } @@ -218,7 +269,7 @@ await _translator.TranslateDocumentAsync( _sourceLanguageCode, _targetLanguageCode!, _options, - _cancellationToken) + ct) .ConfigureAwait(false); } catch { try { outputFileInfo.Delete(); } catch { /* ignored */ } @@ -226,9 +277,9 @@ await _translator.TranslateDocumentAsync( } } - private Task RunWithStreamOutputAsync(Stream outputStream) { + private Task RunViaLibraryToStreamAsync(Stream outputStream, CancellationToken ct) { if (_inputFileInfo != null) { - return RunFromFileToStreamAsync(outputStream); + return RunFromFileToStreamViaLibraryAsync(outputStream, ct); } return _translator.TranslateDocumentAsync( @@ -238,10 +289,10 @@ private Task RunWithStreamOutputAsync(Stream outputStream) { _sourceLanguageCode, _targetLanguageCode!, _options, - _cancellationToken); + ct); } - private async Task RunFromFileToStreamAsync(Stream outputStream) { + private async Task RunFromFileToStreamViaLibraryAsync(Stream outputStream, CancellationToken ct) { using var inputFile = _inputFileInfo!.OpenRead(); await _translator.TranslateDocumentAsync( inputFile, @@ -250,10 +301,50 @@ await _translator.TranslateDocumentAsync( _sourceLanguageCode, _targetLanguageCode!, _options, - _cancellationToken) + 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( @@ -262,6 +353,82 @@ private void EnsureTargetLanguage() { } } + /// + /// 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; @@ -277,10 +444,25 @@ internal DocumentRef(ITranslator translator, DocumentHandle handle) { public Task GetStatusAsync(CancellationToken cancellationToken = default) => _translator.TranslateDocumentStatusAsync(Handle, cancellationToken); - /// Polls until the translation is done or fails. + /// + /// 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)); diff --git a/DeepLTests/FluentDocumentTranslationTest.cs b/DeepLTests/FluentDocumentTranslationTest.cs index 2c6d44b..bb99cce 100644 --- a/DeepLTests/FluentDocumentTranslationTest.cs +++ b/DeepLTests/FluentDocumentTranslationTest.cs @@ -3,6 +3,7 @@ // license that can be found in the LICENSE file. using System; +using System.Collections.Generic; using System.IO; using System.Text; using System.Threading; @@ -136,22 +137,225 @@ public async Task UsingOptionsObject_CopiesFields() { } [Fact] - public async Task WithCancellation_PassesToken() { + public async Task WithCancellation_ExternalCancelPropagatesThroughLinkedToken() { var translator = Substitute.For(); using var input = new MemoryStream(); using var output = new MemoryStream(); using var cts = new CancellationTokenSource(); - await translator.TranslateDocument(input, "in.docx").To("de").WithCancellation(cts.Token).SaveTo(output); + // 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); - await translator.Received(1).TranslateDocumentAsync( - input, - "in.docx", - output, - Arg.Any(), - "de", - Arg.Any(), - cts.Token); + // 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 ---------- diff --git a/samples/FluentApi/Program.cs b/samples/FluentApi/Program.cs index c71026c..dfe9ed9 100644 --- a/samples/FluentApi/Program.cs +++ b/samples/FluentApi/Program.cs @@ -131,6 +131,41 @@ await client 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")); @@ -144,8 +179,8 @@ await client var status = await client.Document(handle).GetStatusAsync(); Console.WriteLine($" split/status : {status.Status} (remaining: {status.SecondsRemaining?.ToString() ?? "n/a"})"); - // Or block until done. - await client.Document(handle).WaitUntilDoneAsync(); + // 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 { From b63a512937400d9ed2b9c61e6fcf59571cf183b7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 21:40:52 +0000 Subject: [PATCH 09/10] fix: enforce WithStyle/WithTone mutual exclusivity in fluent rephrase builder Agent-Logs-Url: https://github.com/DeepLcom/deepl-dotnet/sessions/ff72e677-f6ec-41d2-89db-47ce9d9494e6 Co-authored-by: DeeJayTC <4077759+DeeJayTC@users.noreply.github.com> --- DeepL/FluentTranslation.cs | 32 +++++++++++++--- DeepLTests/FluentTranslationTest.cs | 57 +++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 5 deletions(-) diff --git a/DeepL/FluentTranslation.cs b/DeepL/FluentTranslation.cs index a98589a..ef929aa 100644 --- a/DeepL/FluentTranslation.cs +++ b/DeepL/FluentTranslation.cs @@ -307,21 +307,43 @@ public TSelf To(string? targetLanguageCode) { return Self; } - /// Sets the writing style. Mutually exclusive with . + /// + /// Sets the writing style. Mutually exclusive with ; throws + /// if a tone has already been set on this builder. + /// public TSelf WithStyle(string writingStyle) { - Options.WritingStyle = writingStyle ?? throw new ArgumentNullException(nameof(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 . + /// + /// Sets the writing tone. Mutually exclusive with ; throws + /// if a style has already been set on this builder. + /// public TSelf WithTone(string writingTone) { - Options.WritingTone = writingTone ?? throw new ArgumentNullException(nameof(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. + /// + /// 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; diff --git a/DeepLTests/FluentTranslationTest.cs b/DeepLTests/FluentTranslationTest.cs index 4965884..8f6255d 100644 --- a/DeepLTests/FluentTranslationTest.cs +++ b/DeepLTests/FluentTranslationTest.cs @@ -345,5 +345,62 @@ await writer.Rephrase("Bad").To(null).Using(o => { 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); + } } } From a95637a4780a93522c44168555703f6efbfc4a4d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 21:48:09 +0000 Subject: [PATCH 10/10] fix: validate language codes in FluentGlossary.WithDictionary/FromCsv; guard unsupported minification paths in FluentDocumentTranslation Agent-Logs-Url: https://github.com/DeepLcom/deepl-dotnet/sessions/7bbc1d60-4844-45bd-9a53-7bcbcbe8e1de Co-authored-by: DeeJayTC <4077759+DeeJayTC@users.noreply.github.com> --- DeepL/FluentDocumentTranslation.cs | 19 +++++++ DeepL/FluentGlossary.cs | 20 +++++++ DeepLTests/FluentDocumentTranslationTest.cs | 60 ++++++++++++++++++++- DeepLTests/FluentGlossaryTest.cs | 30 +++++++++++ 4 files changed, 127 insertions(+), 2 deletions(-) diff --git a/DeepL/FluentDocumentTranslation.cs b/DeepL/FluentDocumentTranslation.cs index 254bd3d..9c1e192 100644 --- a/DeepL/FluentDocumentTranslation.cs +++ b/DeepL/FluentDocumentTranslation.cs @@ -141,6 +141,13 @@ public DocumentTranslationBuilder WithGlossaryId(string glossaryId) { } /// 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; @@ -224,6 +231,18 @@ public Task UploadAsync() { } 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. diff --git a/DeepL/FluentGlossary.cs b/DeepL/FluentGlossary.cs index 3ff9362..b203486 100644 --- a/DeepL/FluentGlossary.cs +++ b/DeepL/FluentGlossary.cs @@ -237,6 +237,16 @@ 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( @@ -260,6 +270,16 @@ 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( diff --git a/DeepLTests/FluentDocumentTranslationTest.cs b/DeepLTests/FluentDocumentTranslationTest.cs index bb99cce..6eb2b43 100644 --- a/DeepLTests/FluentDocumentTranslationTest.cs +++ b/DeepLTests/FluentDocumentTranslationTest.cs @@ -125,7 +125,8 @@ public async Task UsingOptionsObject_CopiesFields() { Formality = Formality.More, GlossaryId = "glossary-id", OutputFormat = "docx", - EnableDocumentMinification = true, + // 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); @@ -133,7 +134,6 @@ public async Task UsingOptionsObject_CopiesFields() { Assert.Equal(Formality.More, captured!.Formality); Assert.Equal("glossary-id", captured.GlossaryId); Assert.Equal("docx", captured.OutputFormat); - Assert.True(captured.EnableDocumentMinification); } [Fact] @@ -431,6 +431,62 @@ public void TranslateDocument_NullTranslator_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] diff --git a/DeepLTests/FluentGlossaryTest.cs b/DeepLTests/FluentGlossaryTest.cs index fd0bfeb..55c5929 100644 --- a/DeepLTests/FluentGlossaryTest.cs +++ b/DeepLTests/FluentGlossaryTest.cs @@ -265,5 +265,35 @@ public void Dictionary_EmptyLanguage_Throws() { 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); }); + } } }