From a55bab68b7a4eb5eccc7bd4eb8818c2e8b66f441 Mon Sep 17 00:00:00 2001 From: Piotr Kantorowicz Date: Fri, 28 Mar 2025 21:18:35 +0100 Subject: [PATCH 1/7] Add initial implementation of Diksy translation service - Created solution and project files for Diksy translation service and its OpenAI integration. - Added core classes for language and model management, including AllowedLanguages and AllowedModels. - Implemented translation functionality with OpenAI API through OpenAiTranslator and related services. - Established API structure with controllers, models, and services for handling translation requests and responses. - Included configuration files for app settings and logging. - Added unit tests for schema generation and translation service functionality. --- .editorconfig | 231 ++++++++++++++++++ .../Diksy.Translation.OpenAI.UnitTests.csproj | 33 +++ .../Schema/SchemaGeneratorTests.cs | 118 +++++++++ .../Diksy.Translation.OpenAI.csproj | 23 ++ .../Extensions/ServiceCollectionExtensions.cs | 20 ++ .../Factories/IOpenAiFactory.cs | 9 + .../Factories/OpenAiFactory.cs | 14 ++ Diksy.Translation.OpenAI/OpenAiSettings.cs | 4 + Diksy.Translation.OpenAI/OpenAiTranslator.cs | 70 ++++++ .../Schema/ISchemaGenerator.cs | 7 + .../Schema/SchemaGenerator.cs | 29 +++ Diksy.Translation/AllowedLanguages.cs | 33 +++ Diksy.Translation/AllowedModels.cs | 12 + Diksy.Translation/Diksy.Translation.csproj | 13 + .../Exceptions/TranslationException.cs | 9 + Diksy.Translation/Models/TranslationInfo.cs | 13 + Diksy.Translation/Services/ITranslator.cs | 9 + .../Controllers/TranslationController.cs | 80 ++++++ Diksy.WebApi/Diksy.WebApi.csproj | 21 ++ Diksy.WebApi/Models/ApiProblemDetails.cs | 55 +++++ .../Models/Translation/TranslationInfo.cs | 20 ++ .../Models/Translation/TranslationRequest.cs | 26 ++ .../Models/Translation/TranslationResponse.cs | 20 ++ Diksy.WebApi/Program.cs | 47 ++++ Diksy.WebApi/Properties/launchSettings.json | 23 ++ Diksy.WebApi/Services/ITranslationService.cs | 9 + Diksy.WebApi/Services/TranslationService.cs | 84 +++++++ Diksy.WebApi/appsettings.Development.json | 8 + Diksy.WebApi/appsettings.json | 13 + Diksy.sln | 47 ++++ 30 files changed, 1100 insertions(+) create mode 100644 .editorconfig create mode 100644 Diksy.Translation.OpenAI.UnitTests/Diksy.Translation.OpenAI.UnitTests.csproj create mode 100644 Diksy.Translation.OpenAI.UnitTests/Schema/SchemaGeneratorTests.cs create mode 100644 Diksy.Translation.OpenAI/Diksy.Translation.OpenAI.csproj create mode 100644 Diksy.Translation.OpenAI/Extensions/ServiceCollectionExtensions.cs create mode 100644 Diksy.Translation.OpenAI/Factories/IOpenAiFactory.cs create mode 100644 Diksy.Translation.OpenAI/Factories/OpenAiFactory.cs create mode 100644 Diksy.Translation.OpenAI/OpenAiSettings.cs create mode 100644 Diksy.Translation.OpenAI/OpenAiTranslator.cs create mode 100644 Diksy.Translation.OpenAI/Schema/ISchemaGenerator.cs create mode 100644 Diksy.Translation.OpenAI/Schema/SchemaGenerator.cs create mode 100644 Diksy.Translation/AllowedLanguages.cs create mode 100644 Diksy.Translation/AllowedModels.cs create mode 100644 Diksy.Translation/Diksy.Translation.csproj create mode 100644 Diksy.Translation/Exceptions/TranslationException.cs create mode 100644 Diksy.Translation/Models/TranslationInfo.cs create mode 100644 Diksy.Translation/Services/ITranslator.cs create mode 100644 Diksy.WebApi/Controllers/TranslationController.cs create mode 100644 Diksy.WebApi/Diksy.WebApi.csproj create mode 100644 Diksy.WebApi/Models/ApiProblemDetails.cs create mode 100644 Diksy.WebApi/Models/Translation/TranslationInfo.cs create mode 100644 Diksy.WebApi/Models/Translation/TranslationRequest.cs create mode 100644 Diksy.WebApi/Models/Translation/TranslationResponse.cs create mode 100644 Diksy.WebApi/Program.cs create mode 100644 Diksy.WebApi/Properties/launchSettings.json create mode 100644 Diksy.WebApi/Services/ITranslationService.cs create mode 100644 Diksy.WebApi/Services/TranslationService.cs create mode 100644 Diksy.WebApi/appsettings.Development.json create mode 100644 Diksy.WebApi/appsettings.json create mode 100644 Diksy.sln diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..ab4f3e0 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,231 @@ +# Remove the line below if you want to inherit .editorconfig settings from higher directories +root = true + +# C# files +[*.cs] + +#### Core EditorConfig Options #### + +# Indentation and spacing +indent_size = 4 +indent_style = space +tab_width = 4 + +# New line preferences +end_of_line = crlf +insert_final_newline = false + +#### .NET Coding Conventions #### + +# Organize usings +dotnet_separate_import_directive_groups = false +dotnet_sort_system_directives_first = false +file_header_template = unset + +# this. and Me. preferences +dotnet_style_qualification_for_event = false +dotnet_style_qualification_for_field = false +dotnet_style_qualification_for_method = false +dotnet_style_qualification_for_property = false + +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true +dotnet_style_predefined_type_for_member_access = true + +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity +dotnet_style_parentheses_in_other_operators = never_if_unnecessary +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity + +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members + +# Expression-level preferences +dotnet_style_coalesce_expression = true +dotnet_style_collection_initializer = true +dotnet_style_explicit_tuple_names = true +dotnet_style_namespace_match_folder = true +dotnet_style_null_propagation = true +dotnet_style_object_initializer = true +dotnet_style_operator_placement_when_wrapping = beginning_of_line +dotnet_style_prefer_auto_properties = true +dotnet_style_prefer_collection_expression = when_types_loosely_match +dotnet_style_prefer_compound_assignment = true +dotnet_style_prefer_conditional_expression_over_assignment = true +dotnet_style_prefer_conditional_expression_over_return = true +dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed +dotnet_style_prefer_inferred_anonymous_type_member_names = true +dotnet_style_prefer_inferred_tuple_names = true +dotnet_style_prefer_is_null_check_over_reference_equality_method = true +dotnet_style_prefer_simplified_boolean_expressions = true +dotnet_style_prefer_simplified_interpolation = true + +# Field preferences +dotnet_style_readonly_field = true + +# Parameter preferences +dotnet_code_quality_unused_parameters = all:silent + +# Suppression preferences +dotnet_remove_unnecessary_suppression_exclusions = none + +# New line preferences +dotnet_style_allow_multiple_blank_lines_experimental = true +dotnet_style_allow_statement_immediately_after_block_experimental = true + +#### C# Coding Conventions #### + +# var preferences +csharp_style_var_elsewhere = false +csharp_style_var_for_built_in_types = false +csharp_style_var_when_type_is_apparent = false + +# Expression-bodied members +csharp_style_expression_bodied_accessors = true +csharp_style_expression_bodied_constructors = false +csharp_style_expression_bodied_indexers = true +csharp_style_expression_bodied_lambdas = true +csharp_style_expression_bodied_local_functions = false +csharp_style_expression_bodied_methods = false +csharp_style_expression_bodied_operators = false +csharp_style_expression_bodied_properties = true + +# Pattern matching preferences +csharp_style_pattern_matching_over_as_with_null_check = true +csharp_style_pattern_matching_over_is_with_cast_check = true +csharp_style_prefer_extended_property_pattern = true +csharp_style_prefer_not_pattern = true +csharp_style_prefer_pattern_matching = true +csharp_style_prefer_switch_expression = true + +# Null-checking preferences +csharp_style_conditional_delegate_call = true + +# Modifier preferences +csharp_prefer_static_local_function = true +csharp_preferred_modifier_order = public, private, protected, internal, file, static, extern, new, virtual, abstract, sealed, override, readonly, unsafe, required, volatile, async +csharp_style_prefer_readonly_struct = true +csharp_style_prefer_readonly_struct_member = true + +# Code-block preferences +csharp_prefer_braces = true +csharp_prefer_simple_using_statement = true +csharp_style_namespace_declarations = block_scoped +csharp_style_prefer_method_group_conversion = true +csharp_style_prefer_primary_constructors = true +csharp_style_prefer_top_level_statements = true + +# Expression-level preferences +csharp_prefer_simple_default_expression = true +csharp_style_deconstructed_variable_declaration = true +csharp_style_implicit_object_creation_when_type_is_apparent = true +csharp_style_inlined_variable_declaration = true +csharp_style_prefer_index_operator = true +csharp_style_prefer_local_over_anonymous_function = true +csharp_style_prefer_null_check_over_type_check = true +csharp_style_prefer_range_operator = true +csharp_style_prefer_tuple_swap = true +csharp_style_prefer_utf8_string_literals = true +csharp_style_throw_expression = true +csharp_style_unused_value_assignment_preference = discard_variable +csharp_style_unused_value_expression_statement_preference = discard_variable + +# 'using' directive preferences +csharp_using_directive_placement = outside_namespace + +# New line preferences +csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true +csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true +csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = true +csharp_style_allow_blank_lines_between_consecutive_braces_experimental = true +csharp_style_allow_embedded_statements_on_same_line_experimental = true + +#### C# Formatting Rules #### + +# New line preferences +csharp_new_line_before_catch = true +csharp_new_line_before_else = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_open_brace = all +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_labels = one_less_than_current +csharp_indent_switch_labels = true + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +# Wrapping preferences +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = true + +#### Naming styles #### + +# Naming rules + +dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion +dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface +dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i + +dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.types_should_be_pascal_case.symbols = types +dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case + +dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case + +# Symbol specifications + +dotnet_naming_symbols.interface.applicable_kinds = interface +dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interface.required_modifiers = + +dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum +dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +# Naming styles + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case + +dotnet_naming_style.begins_with_i.required_prefix = I +dotnet_naming_style.begins_with_i.required_suffix = +dotnet_naming_style.begins_with_i.word_separator = +dotnet_naming_style.begins_with_i.capitalization = pascal_case \ No newline at end of file diff --git a/Diksy.Translation.OpenAI.UnitTests/Diksy.Translation.OpenAI.UnitTests.csproj b/Diksy.Translation.OpenAI.UnitTests/Diksy.Translation.OpenAI.UnitTests.csproj new file mode 100644 index 0000000..6cf1b58 --- /dev/null +++ b/Diksy.Translation.OpenAI.UnitTests/Diksy.Translation.OpenAI.UnitTests.csproj @@ -0,0 +1,33 @@ + + + + net9.0 + enable + enable + + false + true + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/Diksy.Translation.OpenAI.UnitTests/Schema/SchemaGeneratorTests.cs b/Diksy.Translation.OpenAI.UnitTests/Schema/SchemaGeneratorTests.cs new file mode 100644 index 0000000..2efe6ed --- /dev/null +++ b/Diksy.Translation.OpenAI.UnitTests/Schema/SchemaGeneratorTests.cs @@ -0,0 +1,118 @@ +using Diksy.Translation.Models; +using Diksy.Translation.OpenAI.Schema; +using NUnit.Framework; +using Shouldly; +using System.Text.Json.Nodes; + +namespace Diksy.Translation.OpenAI.UnitTests.Schema +{ + [TestFixture] + public class SchemaGeneratorTests + { + [SetUp] + public void SetUp() + { + _schemaGenerator = new SchemaGenerator(); + } + + private ISchemaGenerator _schemaGenerator; + + private readonly string[] _requiredProperties = + [ + nameof(TranslationInfo.Phrase), + nameof(TranslationInfo.Translation), + nameof(TranslationInfo.Transcription), + nameof(TranslationInfo.Example) + ]; + + [Test] + public void GenerateSchema_WithRequiredProperties_SetsPropertiesAsRequired() + { + // Arrange + // Act + string schema = _schemaGenerator.GenerateSchema(_requiredProperties); + + // Assert + AssertRequiredPropertiesToContain(schema: schema, requiredProperties: _requiredProperties); + } + + [Test] + public void GenerateSchema_WithoutRequiredProperties_SetsAllPropertiesAsRequired() + { + // Act + string schema = _schemaGenerator.GenerateSchema(); + + // Assert + AssertRequiredPropertiesToBeNullOrEmpty(schema); + } + + [Test] + public void GenerateSchema_WithEmptyRequiredProperties_SetsAllPropertiesAsRequired() + { + // Act + string schema = _schemaGenerator.GenerateSchema([]); + + // Assert + AssertRequiredPropertiesToBeNullOrEmpty(schema); + } + + [Test] + public void GenerateSchema_WithNullRequiredProperties_SetsAllPropertiesAsRequired() + { + // Act + string schema = _schemaGenerator.GenerateSchema(); + + // Assert + AssertRequiredPropertiesToBeNullOrEmpty(schema); + } + + [Test] + public void GenerateSchema_ShouldIncludeCorrectPropertyTypes() + { + // Act + string schema = _schemaGenerator.GenerateSchema(); + + // Assert + JsonNode? jsonSchema = JsonNode.Parse(schema); + JsonNode? properties = jsonSchema?["properties"]; + + properties?["phrase"]?["type"]?.GetValue().ShouldBe("string"); + properties?["translation"]?["type"]?.GetValue().ShouldBe("string"); + properties?["transcription"]?["type"]?.GetValue().ShouldBe("string"); + properties?["example"]?["type"]?.GetValue().ShouldBe("string"); + } + + + [Test] + public void GenerateSchema_ShouldReturnValidJsonString() + { + // Act + string schema = _schemaGenerator.GenerateSchema(); + + // Assert + _ = Should.NotThrow(() => JsonNode.Parse(schema)); + } + + private static void AssertRequiredPropertiesToContain(string schema, string[] requiredProperties) + { + JsonNode? jsonSchema = JsonNode.Parse(schema); + JsonArray? requiredPropertiesNode = jsonSchema?["required"]?.AsArray(); + List? requiredPropertiesCollection = + requiredPropertiesNode?.Select(x => x?.ToString().Trim()).ToList(); + + requiredPropertiesNode.ShouldNotBeNull(); + + foreach (string? requiredProperty in requiredProperties) + { + requiredPropertiesCollection?.ShouldContain(requiredProperty.ToLower()); + } + } + + private static void AssertRequiredPropertiesToBeNullOrEmpty(string schema) + { + JsonNode? jsonSchema = JsonNode.Parse(schema); + JsonNode? requiredPropertiesNode = jsonSchema?["required"]; + requiredPropertiesNode.ShouldBeNull(); + } + } +} \ No newline at end of file diff --git a/Diksy.Translation.OpenAI/Diksy.Translation.OpenAI.csproj b/Diksy.Translation.OpenAI/Diksy.Translation.OpenAI.csproj new file mode 100644 index 0000000..e19a25c --- /dev/null +++ b/Diksy.Translation.OpenAI/Diksy.Translation.OpenAI.csproj @@ -0,0 +1,23 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + + + + + + diff --git a/Diksy.Translation.OpenAI/Extensions/ServiceCollectionExtensions.cs b/Diksy.Translation.OpenAI/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..f8e15bd --- /dev/null +++ b/Diksy.Translation.OpenAI/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,20 @@ +using Diksy.Translation.OpenAI.Factories; +using Diksy.Translation.OpenAI.Schema; +using Diksy.Translation.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace Diksy.Translation.OpenAI.Extensions +{ + public static class ServiceCollectionExtensions + { + public static IServiceCollection AddOpenAiTranslator(this IServiceCollection services, OpenAiSettings settings) + { + services.AddSingleton(settings); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + return services; + } + } +} \ No newline at end of file diff --git a/Diksy.Translation.OpenAI/Factories/IOpenAiFactory.cs b/Diksy.Translation.OpenAI/Factories/IOpenAiFactory.cs new file mode 100644 index 0000000..8540660 --- /dev/null +++ b/Diksy.Translation.OpenAI/Factories/IOpenAiFactory.cs @@ -0,0 +1,9 @@ +using OpenAI; + +namespace Diksy.Translation.OpenAI.Factories +{ + public interface IOpenAiFactory + { + OpenAIClient CreateClient(); + } +} \ No newline at end of file diff --git a/Diksy.Translation.OpenAI/Factories/OpenAiFactory.cs b/Diksy.Translation.OpenAI/Factories/OpenAiFactory.cs new file mode 100644 index 0000000..6b4a11a --- /dev/null +++ b/Diksy.Translation.OpenAI/Factories/OpenAiFactory.cs @@ -0,0 +1,14 @@ +using OpenAI; + +namespace Diksy.Translation.OpenAI.Factories +{ + internal sealed class OpenAiFactory(OpenAiSettings settings) : IOpenAiFactory + { + private readonly OpenAiSettings _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + + public OpenAIClient CreateClient() + { + return new OpenAIClient(_settings.ApiKey); + } + } +} \ No newline at end of file diff --git a/Diksy.Translation.OpenAI/OpenAiSettings.cs b/Diksy.Translation.OpenAI/OpenAiSettings.cs new file mode 100644 index 0000000..5c25492 --- /dev/null +++ b/Diksy.Translation.OpenAI/OpenAiSettings.cs @@ -0,0 +1,4 @@ +namespace Diksy.Translation.OpenAI +{ + public sealed record OpenAiSettings(string? ApiKey, string? DefaultModel); +} \ No newline at end of file diff --git a/Diksy.Translation.OpenAI/OpenAiTranslator.cs b/Diksy.Translation.OpenAI/OpenAiTranslator.cs new file mode 100644 index 0000000..8e36a00 --- /dev/null +++ b/Diksy.Translation.OpenAI/OpenAiTranslator.cs @@ -0,0 +1,70 @@ +using Diksy.Translation.Exceptions; +using Diksy.Translation.Models; +using Diksy.Translation.OpenAI.Factories; +using Diksy.Translation.OpenAI.Schema; +using Diksy.Translation.Services; +using OpenAI; +using OpenAI.Chat; +using System.ClientModel; +using System.Text; +using System.Text.Json; + +namespace Diksy.Translation.OpenAI +{ + internal sealed class OpenAiTranslator(IOpenAiFactory openAiFactory, ISchemaGenerator schemaGenerator) : ITranslator + { + private readonly IOpenAiFactory _openAiFactory = + openAiFactory ?? throw new ArgumentNullException(nameof(openAiFactory)); + + private readonly ISchemaGenerator _schemaGenerator = + schemaGenerator ?? throw new ArgumentNullException(nameof(schemaGenerator)); + + public async Task TranslateAsync(string phrase, string model, string language) + { + OpenAIClient openAiClient = _openAiFactory.CreateClient(); + ChatClient? chatClient = openAiClient.GetChatClient(model); + + string[] requiredProperties = + [ + nameof(TranslationInfo.Phrase), nameof(TranslationInfo.Translation), + nameof(TranslationInfo.Transcription), nameof(TranslationInfo.Example) + ]; + + string jsonSchema = _schemaGenerator.GenerateSchema(requiredProperties); + + ChatCompletionOptions chatCompletionOptions = new() + { + Temperature = 0.15f, + ResponseFormat = ChatResponseFormat.CreateJsonSchemaFormat( + jsonSchemaFormatName: "TranslatorResponse", + jsonSchema: BinaryData.FromString(jsonSchema), + jsonSchemaIsStrict: true) + }; + + string prompt = new StringBuilder() + .Append($"Translate the phrase \"{phrase}\" ") + .Append($"into {language}.") + .AppendLine() + .AppendLine("Please provide:") + .AppendLine("1. Translation that captures the full meaning of the phrase/word") + .AppendLine("2. Phonetic transcription (for each word if it's a phrasal verb)") + .AppendLine("3. Example sentence showing proper usage in context") + .AppendLine() + .AppendLine( + "Note: If this is a phrasal verb or multi-word expression, ensure the translation reflects the complete meaning rather than individual words.") + .ToString(); + + ClientResult openAiResponse = + await chatClient.CompleteChatAsync(messages: [prompt], options: chatCompletionOptions) ?? + throw new TranslationException("Translation response is empty"); + + string jsonResponse = openAiResponse.Value.Content[0].Text ?? + throw new TranslationException("Translation reponse text is empty"); + + TranslationInfo translation = JsonSerializer.Deserialize(jsonResponse) ?? + throw new TranslationException("Unable to deserialize translation response"); + + return translation; + } + } +} \ No newline at end of file diff --git a/Diksy.Translation.OpenAI/Schema/ISchemaGenerator.cs b/Diksy.Translation.OpenAI/Schema/ISchemaGenerator.cs new file mode 100644 index 0000000..1b2ece5 --- /dev/null +++ b/Diksy.Translation.OpenAI/Schema/ISchemaGenerator.cs @@ -0,0 +1,7 @@ +namespace Diksy.Translation.OpenAI.Schema +{ + public interface ISchemaGenerator + { + string GenerateSchema(IEnumerable? requiredProperties = null); + } +} \ No newline at end of file diff --git a/Diksy.Translation.OpenAI/Schema/SchemaGenerator.cs b/Diksy.Translation.OpenAI/Schema/SchemaGenerator.cs new file mode 100644 index 0000000..c85e9c6 --- /dev/null +++ b/Diksy.Translation.OpenAI/Schema/SchemaGenerator.cs @@ -0,0 +1,29 @@ +using NJsonSchema; +using NJsonSchema.Generation; +using System.Text.Json; + +namespace Diksy.Translation.OpenAI.Schema +{ + internal sealed class SchemaGenerator : ISchemaGenerator + { + public string GenerateSchema(IEnumerable? requiredProperties = null) + { + SystemTextJsonSchemaGeneratorSettings schemaGeneratorSettings = new() + { + DefaultReferenceTypeNullHandling = ReferenceTypeNullHandling.NotNull, + SerializerOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase } + }; + + JsonSchema schema = JsonSchemaGenerator.FromType(schemaGeneratorSettings); + List requiredPropertiesToCompare = requiredProperties?.Select(x => x.ToLower()).ToList() ?? []; + + // Set required properties by marking them as required in the schema + foreach (KeyValuePair property in schema.Properties) + { + property.Value.IsRequired = requiredPropertiesToCompare.Contains(property.Key); + } + + return schema.ToJson(); + } + } +} \ No newline at end of file diff --git a/Diksy.Translation/AllowedLanguages.cs b/Diksy.Translation/AllowedLanguages.cs new file mode 100644 index 0000000..dff5be1 --- /dev/null +++ b/Diksy.Translation/AllowedLanguages.cs @@ -0,0 +1,33 @@ +namespace Diksy.Translation +{ + public static class AllowedLanguages + { + public const string English = "English"; + public const string Spanish = "Spanish"; + public const string French = "French"; + public const string German = "German"; + public const string Italian = "Italian"; + public const string Portuguese = "Portuguese"; + public const string Russian = "Russian"; + public const string Chinese = "Chinese"; + public const string Japanese = "Japanese"; + public const string Korean = "Korean"; + public const string Arabic = "Arabic"; + public const string Hindi = "Hindi"; + public const string Dutch = "Dutch"; + public const string Polish = "Polish"; + public const string Turkish = "Turkish"; + + public const string AllLanguagesString = + "English, Spanish, French, German, Italian, Portuguese, Russian, Chinese, Japanese, Korean, Arabic, Hindi, Dutch, Polish, Turkish"; + + public const string LanguageRegex = + "^(English|Spanish|French|German|Italian|Portuguese|Russian|Chinese|Japanese|Korean|Arabic|Hindi|Dutch|Polish|Turkish)$"; + + private static readonly string[] AllLanguages = + [ + English, Spanish, French, German, Italian, Portuguese, Russian, + Chinese, Japanese, Korean, Arabic, Hindi, Dutch, Polish, Turkish + ]; + } +} \ No newline at end of file diff --git a/Diksy.Translation/AllowedModels.cs b/Diksy.Translation/AllowedModels.cs new file mode 100644 index 0000000..0b23e54 --- /dev/null +++ b/Diksy.Translation/AllowedModels.cs @@ -0,0 +1,12 @@ +namespace Diksy.Translation +{ + public static class AllowedModels + { + public const string Gpt4O = "gpt-4o"; + public const string Gpt4OMini = "gpt-4o-mini"; + + public const string AllModelsString = $"{Gpt4O}, {Gpt4OMini}"; + public const string ModelRegex = $"^({Gpt4O}|{Gpt4OMini})$"; + private static readonly string[] AllModels = [Gpt4O, Gpt4OMini]; + } +} \ No newline at end of file diff --git a/Diksy.Translation/Diksy.Translation.csproj b/Diksy.Translation/Diksy.Translation.csproj new file mode 100644 index 0000000..d0f49f1 --- /dev/null +++ b/Diksy.Translation/Diksy.Translation.csproj @@ -0,0 +1,13 @@ + + + + net9.0 + enable + enable + + + + + + + diff --git a/Diksy.Translation/Exceptions/TranslationException.cs b/Diksy.Translation/Exceptions/TranslationException.cs new file mode 100644 index 0000000..4fd7836 --- /dev/null +++ b/Diksy.Translation/Exceptions/TranslationException.cs @@ -0,0 +1,9 @@ +namespace Diksy.Translation.Exceptions +{ + public class TranslationException : Exception + { + public TranslationException(string message) : base(message) + { + } + } +} \ No newline at end of file diff --git a/Diksy.Translation/Models/TranslationInfo.cs b/Diksy.Translation/Models/TranslationInfo.cs new file mode 100644 index 0000000..bcd2ecf --- /dev/null +++ b/Diksy.Translation/Models/TranslationInfo.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; + +namespace Diksy.Translation.Models +{ + public sealed record TranslationInfo( + [property: JsonPropertyName("phrase")] string Phrase, + [property: JsonPropertyName("translation")] + string Translation, + [property: JsonPropertyName("transcription")] + string Transcription, + [property: JsonPropertyName("example")] + string Example); +} \ No newline at end of file diff --git a/Diksy.Translation/Services/ITranslator.cs b/Diksy.Translation/Services/ITranslator.cs new file mode 100644 index 0000000..e3a4ce4 --- /dev/null +++ b/Diksy.Translation/Services/ITranslator.cs @@ -0,0 +1,9 @@ +using Diksy.Translation.Models; + +namespace Diksy.Translation.Services +{ + public interface ITranslator + { + Task TranslateAsync(string word, string model, string language); + } +} \ No newline at end of file diff --git a/Diksy.WebApi/Controllers/TranslationController.cs b/Diksy.WebApi/Controllers/TranslationController.cs new file mode 100644 index 0000000..6e00a47 --- /dev/null +++ b/Diksy.WebApi/Controllers/TranslationController.cs @@ -0,0 +1,80 @@ +using Diksy.WebApi.Models; +using Diksy.WebApi.Models.Translation; +using Diksy.WebApi.Services; +using Microsoft.AspNetCore.Mvc; + +namespace Diksy.WebApi.Controllers +{ + /// + /// Controller for translating text using AI + /// + [ApiController] + [Route("api/[controller]")] + [ProducesResponseType(type: typeof(ApiProblemDetails), statusCode: StatusCodes.Status400BadRequest)] + [ProducesResponseType(type: typeof(ApiProblemDetails), statusCode: StatusCodes.Status500InternalServerError)] + public class TranslationController(ITranslationService translationService, ILogger logger) + : ControllerBase + { + private readonly ILogger _logger = + logger ?? throw new ArgumentNullException(nameof(logger)); + + private readonly ITranslationService _translationService = + translationService ?? throw new ArgumentNullException(nameof(translationService)); + + /// + /// Translates a phrase to the specified language + /// + /// The translation request containing phrase, model, and target language + /// + /// Sample request: + /// POST /api/Translation + /// { + /// "phrase": "Hello world", + /// "model": "gpt-4o", + /// "language": "Spanish" + /// } + /// + /// Returns the translated text with pronunciation and example + /// If the request is invalid or translation fails + /// If there was an internal server error + [HttpPost] + [ProducesResponseType(type: typeof(TranslationResponse), statusCode: StatusCodes.Status200OK)] + [ProducesResponseType(type: typeof(ApiProblemDetails), statusCode: StatusCodes.Status400BadRequest)] + [ProducesResponseType(type: typeof(ApiProblemDetails), statusCode: StatusCodes.Status500InternalServerError)] + public async Task Translate([FromBody] TranslationRequest request) + { + if (!ModelState.IsValid) + { + return BadRequest(new ApiProblemDetails + { + Title = "Validation Failed", + Detail = "One or more validation errors occurred.", + Status = StatusCodes.Status400BadRequest, + Errors = ModelState.ToDictionary( + keySelector: kvp => kvp.Key, + elementSelector: kvp => kvp.Value?.Errors.Select(e => e.ErrorMessage).ToArray() ?? [] + ) + }); + } + + TranslationResponse result = await _translationService.TranslateAsync( + phrase: request.Phrase, + model: request.Model, + language: request.Language); + + if (result.Success) + { + return Ok(result); + } + + _logger.LogError(exception: result.Exception, message: "Error occurred during translation"); + return StatusCode(statusCode: StatusCodes.Status500InternalServerError, + value: new ApiProblemDetails + { + Title = "Translation Error", + Detail = "An unexpected error occurred during translation.", + Status = StatusCodes.Status500InternalServerError + }); + } + } +} \ No newline at end of file diff --git a/Diksy.WebApi/Diksy.WebApi.csproj b/Diksy.WebApi/Diksy.WebApi.csproj new file mode 100644 index 0000000..26fc1e8 --- /dev/null +++ b/Diksy.WebApi/Diksy.WebApi.csproj @@ -0,0 +1,21 @@ + + + + net9.0 + enable + enable + true + $(NoWarn);1591 + + + + + + + + + + + + + diff --git a/Diksy.WebApi/Models/ApiProblemDetails.cs b/Diksy.WebApi/Models/ApiProblemDetails.cs new file mode 100644 index 0000000..0b0ec57 --- /dev/null +++ b/Diksy.WebApi/Models/ApiProblemDetails.cs @@ -0,0 +1,55 @@ +using Microsoft.AspNetCore.Mvc; +using System.Text.Json.Serialization; + +namespace Diksy.WebApi.Models +{ + /// + /// Represents a problem details object that follows the RFC 7807 specification. + /// + public class ApiProblemDetails : ProblemDetails + { + /// + /// Creates a new instance of ApiProblemDetails + /// + public ApiProblemDetails() + { + Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1"; + } + + /// + /// A URI reference that identifies the specific occurrence of the problem + /// + [JsonPropertyName("instance")] + public new string? Instance { get; set; } + + /// + /// A URI reference that identifies the problem type + /// + [JsonPropertyName("type")] + public new string? Type { get; set; } + + /// + /// A short, human-readable summary of the problem + /// + [JsonPropertyName("title")] + public new string? Title { get; set; } + + /// + /// A human-readable explanation specific to this occurrence of the problem + /// + [JsonPropertyName("detail")] + public new string? Detail { get; set; } + + /// + /// The HTTP status code for this occurrence of the problem + /// + [JsonPropertyName("status")] + public new int? Status { get; set; } + + /// + /// Additional details about the error + /// + [JsonPropertyName("errors")] + public IDictionary? Errors { get; set; } + } +} \ No newline at end of file diff --git a/Diksy.WebApi/Models/Translation/TranslationInfo.cs b/Diksy.WebApi/Models/Translation/TranslationInfo.cs new file mode 100644 index 0000000..5611df6 --- /dev/null +++ b/Diksy.WebApi/Models/Translation/TranslationInfo.cs @@ -0,0 +1,20 @@ +namespace Diksy.WebApi.Models.Translation +{ + /// + /// Contains details about a translation + /// + public sealed class TranslationInfo + { + /// The original phrase that was translated + public required string Phrase { get; set; } + + /// The translated text in the target language + public required string Translation { get; set; } + + /// Phonetic transcription of the translated text + public required string Transcription { get; set; } + + /// An example usage of the translated phrase in context + public required string Example { get; set; } + } +} \ No newline at end of file diff --git a/Diksy.WebApi/Models/Translation/TranslationRequest.cs b/Diksy.WebApi/Models/Translation/TranslationRequest.cs new file mode 100644 index 0000000..ef10942 --- /dev/null +++ b/Diksy.WebApi/Models/Translation/TranslationRequest.cs @@ -0,0 +1,26 @@ +using Diksy.Translation; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; + +namespace Diksy.WebApi.Models.Translation +{ + /// + /// Request object for translation requests + /// + public sealed record TranslationRequest + { + /// The phrase to translate (3-30 characters) + [StringLength(30, MinimumLength = 3)] + public required string Phrase { get; set; } + + /// The AI model to use for translation. Defaults to GPT-4 + [RegularExpression(AllowedModels.ModelRegex, ErrorMessage = "Invalid model")] + [DefaultValue(AllowedModels.Gpt4O)] + public string? Model { get; set; } + + /// The target language for translation. Must be one of the supported languages + [RegularExpression(AllowedLanguages.LanguageRegex, ErrorMessage = "Invalid language")] + [DefaultValue(AllowedLanguages.English)] + public string? Language { get; set; } + } +} \ No newline at end of file diff --git a/Diksy.WebApi/Models/Translation/TranslationResponse.cs b/Diksy.WebApi/Models/Translation/TranslationResponse.cs new file mode 100644 index 0000000..312c6e6 --- /dev/null +++ b/Diksy.WebApi/Models/Translation/TranslationResponse.cs @@ -0,0 +1,20 @@ +namespace Diksy.WebApi.Models.Translation +{ + /// + /// Response object for translation requests + /// + public sealed record TranslationResponse + { + /// Indicates if the translation was successful + public bool Success { get; set; } + + /// Contains the translation details if successful + public TranslationInfo? Response { get; set; } + + /// List of error messages if any occurred during translation + public IEnumerable? Errors { get; set; } + + /// Exception that occurred during translation + public Exception? Exception { get; set; } + } +} \ No newline at end of file diff --git a/Diksy.WebApi/Program.cs b/Diksy.WebApi/Program.cs new file mode 100644 index 0000000..c98b3af --- /dev/null +++ b/Diksy.WebApi/Program.cs @@ -0,0 +1,47 @@ +using Diksy.Translation.OpenAI; +using Diksy.Translation.OpenAI.Extensions; +using Diksy.WebApi.Services; +using NSwag; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); + +builder.Services.AddOpenApiDocument(config => +{ + config.PostProcess = document => + { + document.Info.Version = "v1"; + document.Info.Title = "Diksy Translation API"; + document.Info.Description = "API for translating phrases using AI"; + document.Info.Contact = new OpenApiContact { Name = "Support", Email = "support@diksy.com" }; + }; +}); + +OpenAiSettings openAiSettings = builder.Configuration.GetSection("OpenAI").Get() + ?? new OpenAiSettings( + ApiKey: builder.Configuration["OpenAI:ApiKey"] ?? + throw new InvalidOperationException("OpenAI API key is not configured."), + DefaultModel: builder.Configuration["OpenAI:DefaultModel"] ?? "gpt-4o"); + +builder.Services.AddOpenAiTranslator(openAiSettings); +builder.Services.AddScoped(); + +WebApplication app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseOpenApi(); + app.UseSwaggerUi(); + app.UseReDoc(config => + { + config.Path = "/redoc"; + }); +} + +app.UseHttpsRedirection(); +app.UseAuthorization(); +app.MapControllers(); + +app.Run(); \ No newline at end of file diff --git a/Diksy.WebApi/Properties/launchSettings.json b/Diksy.WebApi/Properties/launchSettings.json new file mode 100644 index 0000000..1493d71 --- /dev/null +++ b/Diksy.WebApi/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5014", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7002;http://localhost:5014", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Diksy.WebApi/Services/ITranslationService.cs b/Diksy.WebApi/Services/ITranslationService.cs new file mode 100644 index 0000000..23d0383 --- /dev/null +++ b/Diksy.WebApi/Services/ITranslationService.cs @@ -0,0 +1,9 @@ +using Diksy.WebApi.Models.Translation; + +namespace Diksy.WebApi.Services +{ + public interface ITranslationService + { + Task TranslateAsync(string phrase, string? model, string? language); + } +} \ No newline at end of file diff --git a/Diksy.WebApi/Services/TranslationService.cs b/Diksy.WebApi/Services/TranslationService.cs new file mode 100644 index 0000000..1b1abcf --- /dev/null +++ b/Diksy.WebApi/Services/TranslationService.cs @@ -0,0 +1,84 @@ +using Diksy.Translation; +using Diksy.Translation.Exceptions; +using Diksy.Translation.OpenAI; +using Diksy.Translation.Services; +using Diksy.WebApi.Models.Translation; + +namespace Diksy.WebApi.Services +{ + public class TranslationService( + ITranslator translator, + ILogger logger, + OpenAiSettings openAiSettings) : ITranslationService + { + private readonly ILogger + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + private readonly OpenAiSettings _openAiSettings = + openAiSettings ?? throw new ArgumentNullException(nameof(openAiSettings)); + + private readonly ITranslator _translator = translator ?? throw new ArgumentNullException(nameof(translator)); + + public async Task TranslateAsync(string phrase, string? model, string? language) + { + try + { + string defaultModel = model ?? _openAiSettings.DefaultModel ?? AllowedModels.Gpt4O; + string defaultLanguage = language ?? AllowedLanguages.English; + + _logger.LogInformation(message: "Translating phrase: {Phrase} to {Language} using model {Model}", + phrase, defaultLanguage, defaultModel); + + Translation.Models.TranslationInfo result = + await _translator.TranslateAsync(word: phrase, model: defaultModel, language: defaultLanguage); + + TranslationInfo resultModel = new() + { + Phrase = result.Phrase, + Translation = result.Translation, + Transcription = result.Transcription, + Example = result.Example + }; + + _logger.LogInformation(message: "Successfully translated phrase: {Phrase} to {Translation}", + phrase, result.Translation); + + SanitizeTranslationResponse(phrase: phrase, translation: resultModel); + + return new TranslationResponse { Success = true, Response = resultModel }; + } + catch (Exception ex) + { + _logger.LogError(exception: ex, message: "Error translating phrase: {Phrase}", phrase); + return new TranslationResponse + { + Success = false, Response = null!, Errors = [$"Translation error: {ex.Message}"], Exception = ex + }; + } + } + + private static void SanitizeTranslationResponse(string phrase, TranslationInfo translation) + { + if (string.IsNullOrEmpty(translation.Phrase) || + !translation.Phrase.Equals(value: phrase, comparisonType: StringComparison.OrdinalIgnoreCase)) + { + throw new TranslationException("Phrase is null or empty or is different from the original phrase"); + } + + if (string.IsNullOrEmpty(translation.Translation)) + { + throw new TranslationException("Translation is null or empty"); + } + + if (string.IsNullOrEmpty(translation.Transcription)) + { + throw new TranslationException("Transcription is null or empty"); + } + + if (string.IsNullOrEmpty(translation.Example)) + { + throw new TranslationException("Example is null or empty"); + } + } + } +} \ No newline at end of file diff --git a/Diksy.WebApi/appsettings.Development.json b/Diksy.WebApi/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/Diksy.WebApi/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Diksy.WebApi/appsettings.json b/Diksy.WebApi/appsettings.json new file mode 100644 index 0000000..582574d --- /dev/null +++ b/Diksy.WebApi/appsettings.json @@ -0,0 +1,13 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "OpenAI": { + "ApiKey": "", + "DefaultModel": "" + } +} diff --git a/Diksy.sln b/Diksy.sln new file mode 100644 index 0000000..a0b6682 --- /dev/null +++ b/Diksy.sln @@ -0,0 +1,47 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Diksy.Translation.OpenAI", "Diksy.Translation.OpenAI\Diksy.Translation.OpenAI.csproj", "{370F8BB4-C7D7-4378-B531-E27E00B29E56}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Diksy.Translation", "Diksy.Translation\Diksy.Translation.csproj", "{872D387E-5B3E-473D-BC16-44915DA9E150}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Diksy.WebApi", "Diksy.WebApi\Diksy.WebApi.csproj", "{9957D946-467B-4879-B2AF-36C384AB31F9}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{BF89416C-4B37-4712-8B6B-18016A4CF249}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{F6BC499B-DC93-4606-9B50-1FC816FC42BF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Diksy.Translation.OpenAI.UnitTests", "Diksy.Translation.OpenAI.UnitTests\Diksy.Translation.OpenAI.UnitTests.csproj", "{F28D3B52-BF08-4B66-9B63-CA379F30D507}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {370F8BB4-C7D7-4378-B531-E27E00B29E56}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {370F8BB4-C7D7-4378-B531-E27E00B29E56}.Debug|Any CPU.Build.0 = Debug|Any CPU + {370F8BB4-C7D7-4378-B531-E27E00B29E56}.Release|Any CPU.ActiveCfg = Release|Any CPU + {370F8BB4-C7D7-4378-B531-E27E00B29E56}.Release|Any CPU.Build.0 = Release|Any CPU + {872D387E-5B3E-473D-BC16-44915DA9E150}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {872D387E-5B3E-473D-BC16-44915DA9E150}.Debug|Any CPU.Build.0 = Debug|Any CPU + {872D387E-5B3E-473D-BC16-44915DA9E150}.Release|Any CPU.ActiveCfg = Release|Any CPU + {872D387E-5B3E-473D-BC16-44915DA9E150}.Release|Any CPU.Build.0 = Release|Any CPU + {9957D946-467B-4879-B2AF-36C384AB31F9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9957D946-467B-4879-B2AF-36C384AB31F9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9957D946-467B-4879-B2AF-36C384AB31F9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9957D946-467B-4879-B2AF-36C384AB31F9}.Release|Any CPU.Build.0 = Release|Any CPU + {F28D3B52-BF08-4B66-9B63-CA379F30D507}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F28D3B52-BF08-4B66-9B63-CA379F30D507}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F28D3B52-BF08-4B66-9B63-CA379F30D507}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F28D3B52-BF08-4B66-9B63-CA379F30D507}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {370F8BB4-C7D7-4378-B531-E27E00B29E56} = {BF89416C-4B37-4712-8B6B-18016A4CF249} + {872D387E-5B3E-473D-BC16-44915DA9E150} = {BF89416C-4B37-4712-8B6B-18016A4CF249} + {9957D946-467B-4879-B2AF-36C384AB31F9} = {BF89416C-4B37-4712-8B6B-18016A4CF249} + {F28D3B52-BF08-4B66-9B63-CA379F30D507} = {F6BC499B-DC93-4606-9B50-1FC816FC42BF} + EndGlobalSection +EndGlobal From deb05c94da6df201fb33f3485aad5bc39ae58615 Mon Sep 17 00:00:00 2001 From: Piotr Kantorowicz Date: Sat, 29 Mar 2025 07:54:33 +0100 Subject: [PATCH 2/7] Applied code review suggestions - Refactor translation models and error handling - Update app settings and add mapping functionality --- .../Factories/OpenAiFactory.cs | 5 ++ Diksy.Translation.OpenAI/OpenAiTranslator.cs | 7 ++- .../Exceptions/TranslationException.cs | 5 +- Diksy.Translation/Models/TranslationInfo.cs | 22 +++++--- .../Controllers/TranslationController.cs | 1 - Diksy.WebApi/Diksy.WebApi.csproj | 13 +++-- .../Translation/Maps/TranslationInfoMapper.cs | 30 ++++++++++ .../Models/Translation/TranslationInfo.cs | 30 ++++++++-- .../Models/Translation/TranslationRequest.cs | 29 ++++++++-- .../Models/Translation/TranslationResponse.cs | 55 +++++++++++++++++-- Diksy.WebApi/Services/TranslationService.cs | 26 ++++----- Diksy.WebApi/appsettings.json | 6 +- 12 files changed, 174 insertions(+), 55 deletions(-) create mode 100644 Diksy.WebApi/Models/Translation/Maps/TranslationInfoMapper.cs diff --git a/Diksy.Translation.OpenAI/Factories/OpenAiFactory.cs b/Diksy.Translation.OpenAI/Factories/OpenAiFactory.cs index 6b4a11a..cc84f06 100644 --- a/Diksy.Translation.OpenAI/Factories/OpenAiFactory.cs +++ b/Diksy.Translation.OpenAI/Factories/OpenAiFactory.cs @@ -8,6 +8,11 @@ internal sealed class OpenAiFactory(OpenAiSettings settings) : IOpenAiFactory public OpenAIClient CreateClient() { + if (string.IsNullOrEmpty(_settings.ApiKey)) + { + throw new InvalidOperationException("OpenAI API key is not configured"); + } + return new OpenAIClient(_settings.ApiKey); } } diff --git a/Diksy.Translation.OpenAI/OpenAiTranslator.cs b/Diksy.Translation.OpenAI/OpenAiTranslator.cs index 8e36a00..d441fc9 100644 --- a/Diksy.Translation.OpenAI/OpenAiTranslator.cs +++ b/Diksy.Translation.OpenAI/OpenAiTranslator.cs @@ -58,8 +58,13 @@ public async Task TranslateAsync(string phrase, string model, s await chatClient.CompleteChatAsync(messages: [prompt], options: chatCompletionOptions) ?? throw new TranslationException("Translation response is empty"); + if (openAiResponse.Value.Content.Count == 0) + { + throw new TranslationException("No content returned in translation response"); + } + string jsonResponse = openAiResponse.Value.Content[0].Text ?? - throw new TranslationException("Translation reponse text is empty"); + throw new TranslationException("Translation response text is empty"); TranslationInfo translation = JsonSerializer.Deserialize(jsonResponse) ?? throw new TranslationException("Unable to deserialize translation response"); diff --git a/Diksy.Translation/Exceptions/TranslationException.cs b/Diksy.Translation/Exceptions/TranslationException.cs index 4fd7836..02353bc 100644 --- a/Diksy.Translation/Exceptions/TranslationException.cs +++ b/Diksy.Translation/Exceptions/TranslationException.cs @@ -1,9 +1,6 @@ namespace Diksy.Translation.Exceptions { - public class TranslationException : Exception + public class TranslationException(string message) : Exception(message) { - public TranslationException(string message) : base(message) - { - } } } \ No newline at end of file diff --git a/Diksy.Translation/Models/TranslationInfo.cs b/Diksy.Translation/Models/TranslationInfo.cs index bcd2ecf..a2be479 100644 --- a/Diksy.Translation/Models/TranslationInfo.cs +++ b/Diksy.Translation/Models/TranslationInfo.cs @@ -2,12 +2,18 @@ namespace Diksy.Translation.Models { - public sealed record TranslationInfo( - [property: JsonPropertyName("phrase")] string Phrase, - [property: JsonPropertyName("translation")] - string Translation, - [property: JsonPropertyName("transcription")] - string Transcription, - [property: JsonPropertyName("example")] - string Example); + public class TranslationInfo + { + public TranslationInfo() + { + } + + [JsonPropertyName("phrase")] public required string Phrase { get; init; } + + [JsonPropertyName("translation")] public required string Translation { get; init; } + + [JsonPropertyName("transcription")] public required string Transcription { get; init; } + + [JsonPropertyName("example")] public required string Example { get; init; } + } } \ No newline at end of file diff --git a/Diksy.WebApi/Controllers/TranslationController.cs b/Diksy.WebApi/Controllers/TranslationController.cs index 6e00a47..ac33ea0 100644 --- a/Diksy.WebApi/Controllers/TranslationController.cs +++ b/Diksy.WebApi/Controllers/TranslationController.cs @@ -67,7 +67,6 @@ public async Task Translate([FromBody] TranslationRequest request return Ok(result); } - _logger.LogError(exception: result.Exception, message: "Error occurred during translation"); return StatusCode(statusCode: StatusCodes.Status500InternalServerError, value: new ApiProblemDetails { diff --git a/Diksy.WebApi/Diksy.WebApi.csproj b/Diksy.WebApi/Diksy.WebApi.csproj index 26fc1e8..f109872 100644 --- a/Diksy.WebApi/Diksy.WebApi.csproj +++ b/Diksy.WebApi/Diksy.WebApi.csproj @@ -1,4 +1,4 @@ - + net9.0 @@ -6,16 +6,17 @@ enable true $(NoWarn);1591 - + 4ddcd890-ed7d-46b0-911f-fa3aa9970856 + - - + + - - + + diff --git a/Diksy.WebApi/Models/Translation/Maps/TranslationInfoMapper.cs b/Diksy.WebApi/Models/Translation/Maps/TranslationInfoMapper.cs new file mode 100644 index 0000000..80ec3d8 --- /dev/null +++ b/Diksy.WebApi/Models/Translation/Maps/TranslationInfoMapper.cs @@ -0,0 +1,30 @@ +using TranslationInfoModel = Diksy.Translation.Models.TranslationInfo; +using TranslationInfoDto = Diksy.WebApi.Models.Translation.TranslationInfo; + +namespace Diksy.WebApi.Models.Translation.Maps +{ + public static class TranslationInfoMapper + { + public static TranslationInfoDto MapFrom(TranslationInfoModel translationInfo) + { + return new TranslationInfoDto + { + Phrase = translationInfo.Phrase, + Translation = translationInfo.Translation, + Transcription = translationInfo.Transcription, + Example = translationInfo.Example + }; + } + + public static TranslationInfoModel MapTo(TranslationInfoDto translationInfoDto) + { + return new TranslationInfoModel + { + Phrase = translationInfoDto.Phrase, + Translation = translationInfoDto.Translation, + Transcription = translationInfoDto.Transcription, + Example = translationInfoDto.Example + }; + } + } +} \ No newline at end of file diff --git a/Diksy.WebApi/Models/Translation/TranslationInfo.cs b/Diksy.WebApi/Models/Translation/TranslationInfo.cs index 5611df6..5e312cf 100644 --- a/Diksy.WebApi/Models/Translation/TranslationInfo.cs +++ b/Diksy.WebApi/Models/Translation/TranslationInfo.cs @@ -5,16 +5,38 @@ namespace Diksy.WebApi.Models.Translation /// public sealed class TranslationInfo { + /// + /// Default constructor for TranslationInfo + /// + public TranslationInfo() + { + } + + /// + /// Constructor for TranslationInfo with parameters + /// + /// The original phrase that was translated + /// The translated text in the target language + /// Phonetic transcription of the translated text + /// An example usage of the translated phrase in context + public TranslationInfo(string phrase, string translation, string transcription, string example) + { + Phrase = phrase; + Translation = translation; + Transcription = transcription; + Example = example; + } + /// The original phrase that was translated - public required string Phrase { get; set; } + public required string Phrase { get; init; } /// The translated text in the target language - public required string Translation { get; set; } + public required string Translation { get; init; } /// Phonetic transcription of the translated text - public required string Transcription { get; set; } + public required string Transcription { get; init; } /// An example usage of the translated phrase in context - public required string Example { get; set; } + public required string Example { get; init; } } } \ No newline at end of file diff --git a/Diksy.WebApi/Models/Translation/TranslationRequest.cs b/Diksy.WebApi/Models/Translation/TranslationRequest.cs index ef10942..ddd9b11 100644 --- a/Diksy.WebApi/Models/Translation/TranslationRequest.cs +++ b/Diksy.WebApi/Models/Translation/TranslationRequest.cs @@ -7,20 +7,41 @@ namespace Diksy.WebApi.Models.Translation /// /// Request object for translation requests /// - public sealed record TranslationRequest + public sealed class TranslationRequest { + /// + /// Default constructor for TranslationRequest + /// + public TranslationRequest() + { + } + + /// + /// Constructor for TranslationRequest with parameters + /// + /// The phrase to translate (3-30 characters) + /// The AI model to use for translation. Defaults to GPT-4 + /// The target language for translation. Must be one of the supported languages + public TranslationRequest(string phrase, string? model = AllowedModels.Gpt4O, + string? language = AllowedLanguages.English) + { + Phrase = phrase; + Model = model; + Language = language; + } + /// The phrase to translate (3-30 characters) [StringLength(30, MinimumLength = 3)] - public required string Phrase { get; set; } + public required string Phrase { get; init; } /// The AI model to use for translation. Defaults to GPT-4 [RegularExpression(AllowedModels.ModelRegex, ErrorMessage = "Invalid model")] [DefaultValue(AllowedModels.Gpt4O)] - public string? Model { get; set; } + public string? Model { get; init; } = AllowedModels.Gpt4O; /// The target language for translation. Must be one of the supported languages [RegularExpression(AllowedLanguages.LanguageRegex, ErrorMessage = "Invalid language")] [DefaultValue(AllowedLanguages.English)] - public string? Language { get; set; } + public string? Language { get; init; } = AllowedLanguages.English; } } \ No newline at end of file diff --git a/Diksy.WebApi/Models/Translation/TranslationResponse.cs b/Diksy.WebApi/Models/Translation/TranslationResponse.cs index 312c6e6..314f65a 100644 --- a/Diksy.WebApi/Models/Translation/TranslationResponse.cs +++ b/Diksy.WebApi/Models/Translation/TranslationResponse.cs @@ -3,18 +3,61 @@ namespace Diksy.WebApi.Models.Translation /// /// Response object for translation requests /// - public sealed record TranslationResponse + public sealed class TranslationResponse { + /// + /// Default constructor for TranslationResponse + /// + public TranslationResponse() + { + } + + /// + /// Constructor for TranslationResponse with parameters + /// + /// Indicates if the translation was successful + /// Contains the translation details if successful + /// List of error messages if any occurred during translation + public TranslationResponse(bool success, TranslationInfo? response = null, IEnumerable? errors = null) + { + Success = success; + Response = response; + Errors = errors;} + /// Indicates if the translation was successful - public bool Success { get; set; } + public bool Success { get; init; } /// Contains the translation details if successful - public TranslationInfo? Response { get; set; } + public TranslationInfo? Response { get; init; } /// List of error messages if any occurred during translation - public IEnumerable? Errors { get; set; } + public IEnumerable? Errors { get; init; } + + /// + /// Creates a successful translation response with the provided translation information + /// + /// The translation details to include in the response + /// A TranslationResponse indicating success with the provided translation + public static TranslationResponse SuccessResponse(TranslationInfo translationInfo) + { + return new TranslationResponse + { + Success = true, + Response = translationInfo + }; + } - /// Exception that occurred during translation - public Exception? Exception { get; set; } + /// + /// Creates an error translation response with the provided error message and optional exception + /// + /// The error message describing what went wrong + /// A TranslationResponse indicating failure with the error details + public static TranslationResponse ErrorResponse(string errorMessage) + { + return new TranslationResponse + { + Success = false, + Errors = [errorMessage] }; + } } } \ No newline at end of file diff --git a/Diksy.WebApi/Services/TranslationService.cs b/Diksy.WebApi/Services/TranslationService.cs index 1b1abcf..5d145da 100644 --- a/Diksy.WebApi/Services/TranslationService.cs +++ b/Diksy.WebApi/Services/TranslationService.cs @@ -3,6 +3,9 @@ using Diksy.Translation.OpenAI; using Diksy.Translation.Services; using Diksy.WebApi.Models.Translation; +using TranslationInfoModel = Diksy.Translation.Models.TranslationInfo; +using TranslationInfoDto = Diksy.WebApi.Models.Translation.TranslationInfo; +using Diksy.WebApi.Models.Translation.Maps; namespace Diksy.WebApi.Services { @@ -29,35 +32,26 @@ public async Task TranslateAsync(string phrase, string? mod _logger.LogInformation(message: "Translating phrase: {Phrase} to {Language} using model {Model}", phrase, defaultLanguage, defaultModel); - Translation.Models.TranslationInfo result = + TranslationInfoModel translationInfo = await _translator.TranslateAsync(word: phrase, model: defaultModel, language: defaultLanguage); - TranslationInfo resultModel = new() - { - Phrase = result.Phrase, - Translation = result.Translation, - Transcription = result.Transcription, - Example = result.Example - }; + TranslationInfoDto translationInfoDto = TranslationInfoMapper.MapFrom(translationInfo: translationInfo); _logger.LogInformation(message: "Successfully translated phrase: {Phrase} to {Translation}", - phrase, result.Translation); + phrase, translationInfoDto.Translation); - SanitizeTranslationResponse(phrase: phrase, translation: resultModel); + SanitizeTranslationResponse(phrase: phrase, translation: translationInfoDto); - return new TranslationResponse { Success = true, Response = resultModel }; + return TranslationResponse.SuccessResponse(translationInfoDto); } catch (Exception ex) { _logger.LogError(exception: ex, message: "Error translating phrase: {Phrase}", phrase); - return new TranslationResponse - { - Success = false, Response = null!, Errors = [$"Translation error: {ex.Message}"], Exception = ex - }; + return TranslationResponse.ErrorResponse($"Translation error: {ex.Message}"); } } - private static void SanitizeTranslationResponse(string phrase, TranslationInfo translation) + private static void SanitizeTranslationResponse(string phrase, TranslationInfoDto translation) { if (string.IsNullOrEmpty(translation.Phrase) || !translation.Phrase.Equals(value: phrase, comparisonType: StringComparison.OrdinalIgnoreCase)) diff --git a/Diksy.WebApi/appsettings.json b/Diksy.WebApi/appsettings.json index 582574d..10f68b8 100644 --- a/Diksy.WebApi/appsettings.json +++ b/Diksy.WebApi/appsettings.json @@ -5,9 +5,5 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*", - "OpenAI": { - "ApiKey": "", - "DefaultModel": "" - } + "AllowedHosts": "*" } From cfebe17847740b780915146c418159931e125fc2 Mon Sep 17 00:00:00 2001 From: Piotr Kantorowicz Date: Sat, 29 Mar 2025 08:21:15 +0100 Subject: [PATCH 3/7] Refactor translation models and improve schema generation --- Diksy.Translation.OpenAI/OpenAiTranslator.cs | 4 +++- .../Schema/SchemaGenerator.cs | 2 +- Diksy.Translation/Models/TranslationInfo.cs | 7 +++---- Diksy.WebApi/Diksy.WebApi.csproj | 12 ++++++------ .../Translation/Maps/TranslationInfoMapper.cs | 6 ++++-- .../Models/Translation/TranslationInfo.cs | 9 ++++++++- .../Models/Translation/TranslationResponse.cs | 18 ++++++------------ Diksy.WebApi/Services/TranslationService.cs | 4 ++-- 8 files changed, 33 insertions(+), 29 deletions(-) diff --git a/Diksy.Translation.OpenAI/OpenAiTranslator.cs b/Diksy.Translation.OpenAI/OpenAiTranslator.cs index d441fc9..1ad967d 100644 --- a/Diksy.Translation.OpenAI/OpenAiTranslator.cs +++ b/Diksy.Translation.OpenAI/OpenAiTranslator.cs @@ -27,7 +27,8 @@ public async Task TranslateAsync(string phrase, string model, s string[] requiredProperties = [ nameof(TranslationInfo.Phrase), nameof(TranslationInfo.Translation), - nameof(TranslationInfo.Transcription), nameof(TranslationInfo.Example) + nameof(TranslationInfo.Transcription), nameof(TranslationInfo.Example), + nameof(TranslationInfo.TranslationOfExample) ]; string jsonSchema = _schemaGenerator.GenerateSchema(requiredProperties); @@ -49,6 +50,7 @@ public async Task TranslateAsync(string phrase, string model, s .AppendLine("1. Translation that captures the full meaning of the phrase/word") .AppendLine("2. Phonetic transcription (for each word if it's a phrasal verb)") .AppendLine("3. Example sentence showing proper usage in context") + .AppendLine("4. Translation of the example sentence") .AppendLine() .AppendLine( "Note: If this is a phrasal verb or multi-word expression, ensure the translation reflects the complete meaning rather than individual words.") diff --git a/Diksy.Translation.OpenAI/Schema/SchemaGenerator.cs b/Diksy.Translation.OpenAI/Schema/SchemaGenerator.cs index c85e9c6..b76717b 100644 --- a/Diksy.Translation.OpenAI/Schema/SchemaGenerator.cs +++ b/Diksy.Translation.OpenAI/Schema/SchemaGenerator.cs @@ -20,7 +20,7 @@ public string GenerateSchema(IEnumerable? requiredProperties = null) // Set required properties by marking them as required in the schema foreach (KeyValuePair property in schema.Properties) { - property.Value.IsRequired = requiredPropertiesToCompare.Contains(property.Key); + property.Value.IsRequired = requiredPropertiesToCompare.Contains(property.Key.ToLower()); } return schema.ToJson(); diff --git a/Diksy.Translation/Models/TranslationInfo.cs b/Diksy.Translation/Models/TranslationInfo.cs index a2be479..a4ab128 100644 --- a/Diksy.Translation/Models/TranslationInfo.cs +++ b/Diksy.Translation/Models/TranslationInfo.cs @@ -4,10 +4,6 @@ namespace Diksy.Translation.Models { public class TranslationInfo { - public TranslationInfo() - { - } - [JsonPropertyName("phrase")] public required string Phrase { get; init; } [JsonPropertyName("translation")] public required string Translation { get; init; } @@ -15,5 +11,8 @@ public TranslationInfo() [JsonPropertyName("transcription")] public required string Transcription { get; init; } [JsonPropertyName("example")] public required string Example { get; init; } + + [JsonPropertyName("translationOfExample")] + public required string TranslationOfExample { get; init; } } } \ No newline at end of file diff --git a/Diksy.WebApi/Diksy.WebApi.csproj b/Diksy.WebApi/Diksy.WebApi.csproj index f109872..4fdf453 100644 --- a/Diksy.WebApi/Diksy.WebApi.csproj +++ b/Diksy.WebApi/Diksy.WebApi.csproj @@ -6,17 +6,17 @@ enable true $(NoWarn);1591 - 4ddcd890-ed7d-46b0-911f-fa3aa9970856 - + 4ddcd890-ed7d-46b0-911f-fa3aa9970856 + - - + + - - + + diff --git a/Diksy.WebApi/Models/Translation/Maps/TranslationInfoMapper.cs b/Diksy.WebApi/Models/Translation/Maps/TranslationInfoMapper.cs index 80ec3d8..da057a5 100644 --- a/Diksy.WebApi/Models/Translation/Maps/TranslationInfoMapper.cs +++ b/Diksy.WebApi/Models/Translation/Maps/TranslationInfoMapper.cs @@ -12,7 +12,8 @@ public static TranslationInfoDto MapFrom(TranslationInfoModel translationInfo) Phrase = translationInfo.Phrase, Translation = translationInfo.Translation, Transcription = translationInfo.Transcription, - Example = translationInfo.Example + Example = translationInfo.Example, + TranslationOfExample = translationInfo.TranslationOfExample }; } @@ -23,7 +24,8 @@ public static TranslationInfoModel MapTo(TranslationInfoDto translationInfoDto) Phrase = translationInfoDto.Phrase, Translation = translationInfoDto.Translation, Transcription = translationInfoDto.Transcription, - Example = translationInfoDto.Example + Example = translationInfoDto.Example, + TranslationOfExample = translationInfoDto.TranslationOfExample }; } } diff --git a/Diksy.WebApi/Models/Translation/TranslationInfo.cs b/Diksy.WebApi/Models/Translation/TranslationInfo.cs index 5e312cf..3ea6473 100644 --- a/Diksy.WebApi/Models/Translation/TranslationInfo.cs +++ b/Diksy.WebApi/Models/Translation/TranslationInfo.cs @@ -19,12 +19,16 @@ public TranslationInfo() /// The translated text in the target language /// Phonetic transcription of the translated text /// An example usage of the translated phrase in context - public TranslationInfo(string phrase, string translation, string transcription, string example) + /// Translation of the example sentence + public TranslationInfo(string phrase, string translation, string transcription, string example, + string translationOfExample) + : this() { Phrase = phrase; Translation = translation; Transcription = transcription; Example = example; + TranslationOfExample = translationOfExample; } /// The original phrase that was translated @@ -38,5 +42,8 @@ public TranslationInfo(string phrase, string translation, string transcription, /// An example usage of the translated phrase in context public required string Example { get; init; } + + /// Translation of the example sentence + public required string TranslationOfExample { get; init; } } } \ No newline at end of file diff --git a/Diksy.WebApi/Models/Translation/TranslationResponse.cs b/Diksy.WebApi/Models/Translation/TranslationResponse.cs index 314f65a..e2759c9 100644 --- a/Diksy.WebApi/Models/Translation/TranslationResponse.cs +++ b/Diksy.WebApi/Models/Translation/TranslationResponse.cs @@ -22,7 +22,8 @@ public TranslationResponse(bool success, TranslationInfo? response = null, IEnum { Success = success; Response = response; - Errors = errors;} + Errors = errors; + } /// Indicates if the translation was successful public bool Success { get; init; } @@ -34,30 +35,23 @@ public TranslationResponse(bool success, TranslationInfo? response = null, IEnum public IEnumerable? Errors { get; init; } /// - /// Creates a successful translation response with the provided translation information + /// Creates a successful translation response with the provided translation information /// /// The translation details to include in the response /// A TranslationResponse indicating success with the provided translation public static TranslationResponse SuccessResponse(TranslationInfo translationInfo) { - return new TranslationResponse - { - Success = true, - Response = translationInfo - }; + return new TranslationResponse { Success = true, Response = translationInfo }; } /// - /// Creates an error translation response with the provided error message and optional exception + /// Creates an error translation response with the provided error message and optional exception /// /// The error message describing what went wrong /// A TranslationResponse indicating failure with the error details public static TranslationResponse ErrorResponse(string errorMessage) { - return new TranslationResponse - { - Success = false, - Errors = [errorMessage] }; + return new TranslationResponse { Success = false, Errors = [errorMessage] }; } } } \ No newline at end of file diff --git a/Diksy.WebApi/Services/TranslationService.cs b/Diksy.WebApi/Services/TranslationService.cs index 5d145da..8c39a39 100644 --- a/Diksy.WebApi/Services/TranslationService.cs +++ b/Diksy.WebApi/Services/TranslationService.cs @@ -3,9 +3,9 @@ using Diksy.Translation.OpenAI; using Diksy.Translation.Services; using Diksy.WebApi.Models.Translation; +using Diksy.WebApi.Models.Translation.Maps; using TranslationInfoModel = Diksy.Translation.Models.TranslationInfo; using TranslationInfoDto = Diksy.WebApi.Models.Translation.TranslationInfo; -using Diksy.WebApi.Models.Translation.Maps; namespace Diksy.WebApi.Services { @@ -35,7 +35,7 @@ public async Task TranslateAsync(string phrase, string? mod TranslationInfoModel translationInfo = await _translator.TranslateAsync(word: phrase, model: defaultModel, language: defaultLanguage); - TranslationInfoDto translationInfoDto = TranslationInfoMapper.MapFrom(translationInfo: translationInfo); + TranslationInfoDto translationInfoDto = TranslationInfoMapper.MapFrom(translationInfo: translationInfo); _logger.LogInformation(message: "Successfully translated phrase: {Phrase} to {Translation}", phrase, translationInfoDto.Translation); From 2802711792e48d976aa2a9e1b505ff008b14c48c Mon Sep 17 00:00:00 2001 From: Piotr Kantorowicz Date: Sat, 29 Mar 2025 14:49:36 +0100 Subject: [PATCH 4/7] Enhance translation service with cancellation support and improved error handling - Refactor TranslationException to include inner exception handling. - Update ITranslator and ITranslationService interfaces to support CancellationToken. - Modify OpenAiTranslator to utilize a new ChatClientTranslationService for translation requests. - Implement ChatClientTranslationService for handling OpenAI chat client interactions. - Add unit tests for OpenAiTranslator to validate translation functionality and error scenarios. - Update TranslationController to pass CancellationToken in translation requests. --- .../OpenAiTranslatorTests.cs | 85 +++++++++++++++++++ .../Extensions/ServiceCollectionExtensions.cs | 4 +- Diksy.Translation.OpenAI/OpenAiTranslator.cs | 50 ++++++----- .../Services/ChatClientTranslationService.cs | 28 ++++++ .../Services/IChatClientTranslationService.cs | 13 +++ .../Exceptions/TranslationException.cs | 10 ++- Diksy.Translation/Services/ITranslator.cs | 3 +- .../Controllers/TranslationController.cs | 3 +- Diksy.WebApi/Services/ITranslationService.cs | 3 +- Diksy.WebApi/Services/TranslationService.cs | 6 +- 10 files changed, 179 insertions(+), 26 deletions(-) create mode 100644 Diksy.Translation.OpenAI.UnitTests/OpenAiTranslatorTests.cs create mode 100644 Diksy.Translation.OpenAI/Services/ChatClientTranslationService.cs create mode 100644 Diksy.Translation.OpenAI/Services/IChatClientTranslationService.cs diff --git a/Diksy.Translation.OpenAI.UnitTests/OpenAiTranslatorTests.cs b/Diksy.Translation.OpenAI.UnitTests/OpenAiTranslatorTests.cs new file mode 100644 index 0000000..b7ebff7 --- /dev/null +++ b/Diksy.Translation.OpenAI.UnitTests/OpenAiTranslatorTests.cs @@ -0,0 +1,85 @@ +using Diksy.Translation.Exceptions; +using Diksy.Translation.Models; +using Diksy.Translation.OpenAI.Schema; +using Diksy.Translation.OpenAI.Services; +using Diksy.Translation.Services; +using Moq; +using NUnit.Framework; +using OpenAI.Chat; +using Shouldly; +using System.Text.Json; + +namespace Diksy.Translation.OpenAI.UnitTests +{ + [TestFixture] + public class OpenAiTranslatorTests + { + [SetUp] + public void SetUp() + { + _chatClientTranslationServiceMock = new Mock(); + _schemaGeneratorMock = new SchemaGenerator(); + _translator = new OpenAiTranslator(chatClientTranslationService: _chatClientTranslationServiceMock.Object, + schemaGenerator: _schemaGeneratorMock); + } + + private Mock _chatClientTranslationServiceMock; + private ISchemaGenerator _schemaGeneratorMock; + private ITranslator _translator; + + [Test] + public async Task TranslateAsync_ShouldReturnTranslationInfo_WhenResponseIsValid() + { + // Arrange + string expectedJsonResponse = + "{\"phrase\":\"Hello\",\"translation\":\"Hola\",\"transcription\":\"həˈloʊ\",\"example\":\"Hola, ¿cómo estás?\", \"translationOfExample\":\"Hello, how are you?\"}"; + + TranslationInfo? expectedTranslationInfo = + JsonSerializer.Deserialize(expectedJsonResponse); + + _chatClientTranslationServiceMock.Setup(c => c.TranslateAsync(It.IsAny(), + It.IsAny(), It.IsAny(), CancellationToken.None)) + .ReturnsAsync(new ChatMessageContent(expectedJsonResponse)); + + // Act + TranslationInfo result = await _translator.TranslateAsync(word: "Hello", model: "gpt-4o", + language: "Spanish", cancellationToken: It.IsAny()); + + // Assert + result.ShouldNotBeNull(); + result.Phrase.ShouldBe(expectedTranslationInfo?.Phrase); + result.Translation.ShouldBe(expectedTranslationInfo?.Translation); + result.Transcription.ShouldBe(expectedTranslationInfo?.Transcription); + result.Example.ShouldBe(expectedTranslationInfo?.Example); + result.TranslationOfExample.ShouldBe(expectedTranslationInfo?.TranslationOfExample); + } + + [Test] + public async Task TranslateAsync_ShouldThrowTranslationException_WhenResponseIsEmpty() + { + // Arrange + _chatClientTranslationServiceMock.Setup(c => c.TranslateAsync(It.IsAny(), + It.IsAny(), It.IsAny(), CancellationToken.None)) + .ReturnsAsync(new ChatMessageContent(string.Empty)); + + // Act & Assert + await Should.ThrowAsync(async () => + await _translator.TranslateAsync(word: "Hello", model: "gpt-4o", language: "Spanish", + cancellationToken: It.IsAny())); + } + + [Test] + public async Task TranslateAsync_ShouldThrowTranslationException_WhenResponseIsInvalid() + { + // Arrange + _chatClientTranslationServiceMock.Setup(c => c.TranslateAsync(It.IsAny(), + It.IsAny(), It.IsAny(), CancellationToken.None)) + .ReturnsAsync(new ChatMessageContent("Invalid response")); + + // Act & Assert + await Should.ThrowAsync(async () => + await _translator.TranslateAsync(word: "Hello", model: "gpt-4o", language: "Spanish", + cancellationToken: It.IsAny())); + } + } +} \ No newline at end of file diff --git a/Diksy.Translation.OpenAI/Extensions/ServiceCollectionExtensions.cs b/Diksy.Translation.OpenAI/Extensions/ServiceCollectionExtensions.cs index f8e15bd..f3ec244 100644 --- a/Diksy.Translation.OpenAI/Extensions/ServiceCollectionExtensions.cs +++ b/Diksy.Translation.OpenAI/Extensions/ServiceCollectionExtensions.cs @@ -1,5 +1,6 @@ using Diksy.Translation.OpenAI.Factories; using Diksy.Translation.OpenAI.Schema; +using Diksy.Translation.OpenAI.Services; using Diksy.Translation.Services; using Microsoft.Extensions.DependencyInjection; @@ -12,7 +13,8 @@ public static IServiceCollection AddOpenAiTranslator(this IServiceCollection ser services.AddSingleton(settings); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); return services; } diff --git a/Diksy.Translation.OpenAI/OpenAiTranslator.cs b/Diksy.Translation.OpenAI/OpenAiTranslator.cs index 1ad967d..56922e5 100644 --- a/Diksy.Translation.OpenAI/OpenAiTranslator.cs +++ b/Diksy.Translation.OpenAI/OpenAiTranslator.cs @@ -1,29 +1,28 @@ using Diksy.Translation.Exceptions; using Diksy.Translation.Models; -using Diksy.Translation.OpenAI.Factories; using Diksy.Translation.OpenAI.Schema; +using Diksy.Translation.OpenAI.Services; using Diksy.Translation.Services; -using OpenAI; using OpenAI.Chat; -using System.ClientModel; using System.Text; using System.Text.Json; namespace Diksy.Translation.OpenAI { - internal sealed class OpenAiTranslator(IOpenAiFactory openAiFactory, ISchemaGenerator schemaGenerator) : ITranslator + internal sealed class OpenAiTranslator( + IClientTranslationService chatClientTranslationService, + ISchemaGenerator schemaGenerator) : ITranslator { - private readonly IOpenAiFactory _openAiFactory = - openAiFactory ?? throw new ArgumentNullException(nameof(openAiFactory)); + private readonly IClientTranslationService _chatClientTranslationService = + chatClientTranslationService ?? throw new ArgumentNullException(nameof(chatClientTranslationService)); private readonly ISchemaGenerator _schemaGenerator = schemaGenerator ?? throw new ArgumentNullException(nameof(schemaGenerator)); - public async Task TranslateAsync(string phrase, string model, string language) - { - OpenAIClient openAiClient = _openAiFactory.CreateClient(); - ChatClient? chatClient = openAiClient.GetChatClient(model); + public async Task TranslateAsync(string phrase, string model, string language, + CancellationToken cancellationToken) + { string[] requiredProperties = [ nameof(TranslationInfo.Phrase), nameof(TranslationInfo.Translation), @@ -56,22 +55,35 @@ public async Task TranslateAsync(string phrase, string model, s "Note: If this is a phrasal verb or multi-word expression, ensure the translation reflects the complete meaning rather than individual words.") .ToString(); - ClientResult openAiResponse = - await chatClient.CompleteChatAsync(messages: [prompt], options: chatCompletionOptions) ?? - throw new TranslationException("Translation response is empty"); + ChatMessageContent openAiResponse = + await _chatClientTranslationService.TranslateAsync(prompt: prompt, model: model, + options: chatCompletionOptions, + cancellationToken: cancellationToken); - if (openAiResponse.Value.Content.Count == 0) + if (openAiResponse.Count == 0) { throw new TranslationException("No content returned in translation response"); } - string jsonResponse = openAiResponse.Value.Content[0].Text ?? - throw new TranslationException("Translation response text is empty"); + string responseText = openAiResponse[0].Text; - TranslationInfo translation = JsonSerializer.Deserialize(jsonResponse) ?? - throw new TranslationException("Unable to deserialize translation response"); + string jsonResponse = !string.IsNullOrWhiteSpace(responseText) + ? responseText + : throw new TranslationException("Translation response text is empty"); + + try + { + TranslationInfo translation = JsonSerializer.Deserialize(jsonResponse) ?? + throw new TranslationException( + "Unable to deserialize translation response"); - return translation; + return translation; + } + catch (JsonException jsonException) + { + throw new TranslationException(message: "Unable to deserialize translation response", + innerException: jsonException); + } } } } \ No newline at end of file diff --git a/Diksy.Translation.OpenAI/Services/ChatClientTranslationService.cs b/Diksy.Translation.OpenAI/Services/ChatClientTranslationService.cs new file mode 100644 index 0000000..e6e2c8b --- /dev/null +++ b/Diksy.Translation.OpenAI/Services/ChatClientTranslationService.cs @@ -0,0 +1,28 @@ +using Diksy.Translation.Exceptions; +using Diksy.Translation.OpenAI.Factories; +using OpenAI.Chat; +using System.ClientModel; + +namespace Diksy.Translation.OpenAI.Services +{ + internal sealed class ChatTranslationService(IOpenAiFactory openAiFactory, OpenAiSettings settings) + : IClientTranslationService + { + private readonly IOpenAiFactory _openAiFactory = + openAiFactory ?? throw new ArgumentNullException(nameof(openAiFactory)); + + private readonly OpenAiSettings _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + + public async Task TranslateAsync(string prompt, string? model, + ChatCompletionOptions options, CancellationToken cancellationToken = default) + { + ChatClient? chatClient = _openAiFactory.CreateClient().GetChatClient(model ?? _settings.DefaultModel); + ClientResult openAiResponse = + await chatClient.CompleteChatAsync(messages: [prompt], options: options, + cancellationToken: cancellationToken) ?? + throw new TranslationException("Translation response is empty"); + + return openAiResponse.Value.Content; + } + } +} \ No newline at end of file diff --git a/Diksy.Translation.OpenAI/Services/IChatClientTranslationService.cs b/Diksy.Translation.OpenAI/Services/IChatClientTranslationService.cs new file mode 100644 index 0000000..7803d8b --- /dev/null +++ b/Diksy.Translation.OpenAI/Services/IChatClientTranslationService.cs @@ -0,0 +1,13 @@ +using OpenAI.Chat; + +namespace Diksy.Translation.OpenAI.Services +{ + public interface IClientTranslationService + { + Task TranslateAsync( + string prompt, + string? model, + ChatCompletionOptions options, + CancellationToken cancellationToken = default); + } +} \ No newline at end of file diff --git a/Diksy.Translation/Exceptions/TranslationException.cs b/Diksy.Translation/Exceptions/TranslationException.cs index 02353bc..bac2ca7 100644 --- a/Diksy.Translation/Exceptions/TranslationException.cs +++ b/Diksy.Translation/Exceptions/TranslationException.cs @@ -1,6 +1,14 @@ namespace Diksy.Translation.Exceptions { - public class TranslationException(string message) : Exception(message) + public class TranslationException : Exception { + public TranslationException(string message) : base(message) + { + } + + public TranslationException(string message, Exception innerException) : base(message: message, + innerException: innerException) + { + } } } \ No newline at end of file diff --git a/Diksy.Translation/Services/ITranslator.cs b/Diksy.Translation/Services/ITranslator.cs index e3a4ce4..72062c9 100644 --- a/Diksy.Translation/Services/ITranslator.cs +++ b/Diksy.Translation/Services/ITranslator.cs @@ -4,6 +4,7 @@ namespace Diksy.Translation.Services { public interface ITranslator { - Task TranslateAsync(string word, string model, string language); + Task TranslateAsync(string word, string model, string language, + CancellationToken cancellationToken); } } \ No newline at end of file diff --git a/Diksy.WebApi/Controllers/TranslationController.cs b/Diksy.WebApi/Controllers/TranslationController.cs index ac33ea0..e2bd317 100644 --- a/Diksy.WebApi/Controllers/TranslationController.cs +++ b/Diksy.WebApi/Controllers/TranslationController.cs @@ -60,7 +60,8 @@ public async Task Translate([FromBody] TranslationRequest request TranslationResponse result = await _translationService.TranslateAsync( phrase: request.Phrase, model: request.Model, - language: request.Language); + language: request.Language, + cancellationToken: default); if (result.Success) { diff --git a/Diksy.WebApi/Services/ITranslationService.cs b/Diksy.WebApi/Services/ITranslationService.cs index 23d0383..d319e34 100644 --- a/Diksy.WebApi/Services/ITranslationService.cs +++ b/Diksy.WebApi/Services/ITranslationService.cs @@ -4,6 +4,7 @@ namespace Diksy.WebApi.Services { public interface ITranslationService { - Task TranslateAsync(string phrase, string? model, string? language); + Task TranslateAsync(string phrase, string? model, string? language, + CancellationToken cancellationToken); } } \ No newline at end of file diff --git a/Diksy.WebApi/Services/TranslationService.cs b/Diksy.WebApi/Services/TranslationService.cs index 8c39a39..38ada34 100644 --- a/Diksy.WebApi/Services/TranslationService.cs +++ b/Diksy.WebApi/Services/TranslationService.cs @@ -22,7 +22,8 @@ private readonly ILogger private readonly ITranslator _translator = translator ?? throw new ArgumentNullException(nameof(translator)); - public async Task TranslateAsync(string phrase, string? model, string? language) + public async Task TranslateAsync(string phrase, string? model, string? language, + CancellationToken cancellationToken) { try { @@ -33,7 +34,8 @@ public async Task TranslateAsync(string phrase, string? mod phrase, defaultLanguage, defaultModel); TranslationInfoModel translationInfo = - await _translator.TranslateAsync(word: phrase, model: defaultModel, language: defaultLanguage); + await _translator.TranslateAsync(word: phrase, model: defaultModel, language: defaultLanguage, + cancellationToken: cancellationToken); TranslationInfoDto translationInfoDto = TranslationInfoMapper.MapFrom(translationInfo: translationInfo); From cfb90ff5c756df52f71aa09e121da17d387c5e21 Mon Sep 17 00:00:00 2001 From: Piotr Kantorowicz Date: Sat, 29 Mar 2025 15:09:10 +0100 Subject: [PATCH 5/7] Add unit tests for TranslationController and TranslationService - Introduced unit tests for TranslationController to validate translation requests and responses, including success and error scenarios. - Added unit tests for TranslationService to ensure correct handling of translation logic, including default model and language usage. - Created a new project file for Diksy.WebApi.UnitTests to organize unit tests for the Web API components. - Updated Diksy.WebApi project to include InternalsVisibleTo attribute for unit testing access. --- .../Controllers/TranslationControllerTests.cs | 135 +++++++++++++ .../Diksy.WebApi.UnitTests.csproj | 33 +++ .../Services/TranslationServiceTests.cs | 190 ++++++++++++++++++ Diksy.WebApi/Diksy.WebApi.csproj | 6 + Diksy.sln | 10 +- 5 files changed, 372 insertions(+), 2 deletions(-) create mode 100644 Diksy.WebApi.UnitTests/Controllers/TranslationControllerTests.cs create mode 100644 Diksy.WebApi.UnitTests/Diksy.WebApi.UnitTests.csproj create mode 100644 Diksy.WebApi.UnitTests/Services/TranslationServiceTests.cs diff --git a/Diksy.WebApi.UnitTests/Controllers/TranslationControllerTests.cs b/Diksy.WebApi.UnitTests/Controllers/TranslationControllerTests.cs new file mode 100644 index 0000000..a531a9a --- /dev/null +++ b/Diksy.WebApi.UnitTests/Controllers/TranslationControllerTests.cs @@ -0,0 +1,135 @@ +using Diksy.WebApi.Controllers; +using Diksy.WebApi.Models; +using Diksy.WebApi.Models.Translation; +using Diksy.WebApi.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using Shouldly; +using System.Net; + +namespace Diksy.WebApi.UnitTests.Controllers +{ + [TestFixture] + public class TranslationControllerTests + { + private Mock _translationServiceMock; + private Mock> _loggerMock; + private TranslationController _controller; + + [SetUp] + public void SetUp() + { + _translationServiceMock = new Mock(); + _loggerMock = new Mock>(); + + _controller = new TranslationController( + translationService: _translationServiceMock.Object, + logger: _loggerMock.Object); + } + + [Test] + public async Task Translate_WithValidRequest_ReturnsOkResult() + { + // Arrange + var request = new TranslationRequest + { + Phrase = "Hello", + Model = "gpt-4o", + Language = "Spanish" + }; + + var translationInfo = new TranslationInfo + { + Phrase = "Hello", + Translation = "Hola", + Transcription = "həˈloʊ", + Example = "Hola, ¿cómo estás?", + TranslationOfExample = "Hello, how are you?" + }; + + var expectedResponse = TranslationResponse.SuccessResponse(translationInfo); + + _translationServiceMock.Setup(s => s.TranslateAsync( + request.Phrase, request.Model, request.Language, It.IsAny())) + .ReturnsAsync(expectedResponse); + + // Act + var result = await _controller.Translate(request); + + // Assert + var okResult = result.ShouldBeOfType(); + okResult.StatusCode.ShouldBe(StatusCodes.Status200OK); + + var responseValue = okResult.Value.ShouldBeOfType(); + responseValue.Success.ShouldBeTrue(); + responseValue.Response.ShouldNotBeNull(); + responseValue.Response.Phrase.ShouldBe("Hello"); + responseValue.Response.Translation.ShouldBe("Hola"); + responseValue.Response.Transcription.ShouldBe("həˈloʊ"); + responseValue.Response.Example.ShouldBe("Hola, ¿cómo estás?"); + responseValue.Response.TranslationOfExample.ShouldBe("Hello, how are you?"); + } + + [Test] + public async Task Translate_WithInvalidRequest_ReturnsBadRequest() + { + // Arrange + var request = new TranslationRequest + { + Phrase = "Hi", + Model = "gpt-4o", + Language = "Spanish" + }; + + _controller.ModelState.AddModelError("Phrase", "The field Phrase must be a string with a minimum length of 3 and a maximum length of 30."); + + // Act + var result = await _controller.Translate(request); + + // Assert + var badRequestResult = result.ShouldBeOfType(); + badRequestResult.StatusCode.ShouldBe(StatusCodes.Status400BadRequest); + + var problemDetails = badRequestResult.Value.ShouldBeOfType(); + problemDetails.Title.ShouldBe("Validation Failed"); + problemDetails.Status.ShouldBe(StatusCodes.Status400BadRequest); + problemDetails?.Errors?.ShouldContainKey("Phrase"); + } + + [Test] + public async Task Translate_WhenTranslationFails_ReturnsInternalServerError() + { + // Arrange + var request = new TranslationRequest + { + Phrase = "Hello", + Model = "gpt-4o", + Language = "Spanish" + }; + + var failedResponse = new TranslationResponse + { + Success = false, + Errors = ["Translation service error"] + }; + + _translationServiceMock.Setup(s => s.TranslateAsync( + request.Phrase, request.Model, request.Language, It.IsAny())) + .ReturnsAsync(failedResponse); + + // Act + var result = await _controller.Translate(request); + + // Assert + var statusCodeResult = result.ShouldBeOfType(); + statusCodeResult.StatusCode.ShouldBe(StatusCodes.Status500InternalServerError); + + var problemDetails = statusCodeResult.Value.ShouldBeOfType(); + problemDetails.Title.ShouldBe("Translation Error"); + problemDetails.Status.ShouldBe(StatusCodes.Status500InternalServerError); + } + } +} \ No newline at end of file diff --git a/Diksy.WebApi.UnitTests/Diksy.WebApi.UnitTests.csproj b/Diksy.WebApi.UnitTests/Diksy.WebApi.UnitTests.csproj new file mode 100644 index 0000000..234f80b --- /dev/null +++ b/Diksy.WebApi.UnitTests/Diksy.WebApi.UnitTests.csproj @@ -0,0 +1,33 @@ + + + + net9.0 + enable + enable + + false + true + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + \ No newline at end of file diff --git a/Diksy.WebApi.UnitTests/Services/TranslationServiceTests.cs b/Diksy.WebApi.UnitTests/Services/TranslationServiceTests.cs new file mode 100644 index 0000000..1c1f484 --- /dev/null +++ b/Diksy.WebApi.UnitTests/Services/TranslationServiceTests.cs @@ -0,0 +1,190 @@ +using Diksy.Translation; +using Diksy.Translation.Exceptions; +using Diksy.Translation.Models; +using Diksy.Translation.OpenAI; +using Diksy.Translation.Services; +using Diksy.WebApi.Models.Translation; +using Diksy.WebApi.Services; +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using Shouldly; +using TranslationInfoModel = Diksy.Translation.Models.TranslationInfo; + +namespace Diksy.WebApi.UnitTests.Services +{ + [TestFixture] + public class TranslationServiceTests + { + private Mock _translatorMock; + private Mock> _loggerMock; + private OpenAiSettings _openAiSettings; + private TranslationService _service; + + [SetUp] + public void SetUp() + { + _translatorMock = new Mock(); + _loggerMock = new Mock>(); + _openAiSettings = new OpenAiSettings(ApiKey: "test-api-key", DefaultModel: AllowedModels.Gpt4O); + + _service = new TranslationService( + translator: _translatorMock.Object, + logger: _loggerMock.Object, + openAiSettings: _openAiSettings); + } + + [Test] + public async Task TranslateAsync_WithValidInput_ReturnsSuccessfulResponse() + { + // Arrange + string phrase = "Hello"; + string model = "gpt-4o"; + string language = "Spanish"; + + var translationInfoModel = new TranslationInfoModel + { + Phrase = phrase, + Translation = "Hola", + Transcription = "həˈloʊ", + Example = "Hola, ¿cómo estás?", + TranslationOfExample = "Hello, how are you?" + }; + + _translatorMock.Setup(t => t.TranslateAsync( + phrase, model, language, It.IsAny())) + .ReturnsAsync(translationInfoModel); + + // Act + var result = await _service.TranslateAsync(phrase, model, language, CancellationToken.None); + + // Assert + result.ShouldNotBeNull(); + result.Success.ShouldBeTrue(); + result.Response.ShouldNotBeNull(); + result.Response.Phrase.ShouldBe(phrase); + result.Response.Translation.ShouldBe("Hola"); + result.Response.Transcription.ShouldBe("həˈloʊ"); + result.Response.Example.ShouldBe("Hola, ¿cómo estás?"); + result.Response.TranslationOfExample.ShouldBe("Hello, how are you?"); + } + + [Test] + public async Task TranslateAsync_WithNullModel_UsesDefaultModel() + { + // Arrange + string phrase = "Hello"; + string? model = null; + string language = "Spanish"; + string expectedModel = _openAiSettings?.DefaultModel ?? AllowedModels.Gpt4O; + + var translationInfoModel = new TranslationInfoModel + { + Phrase = phrase, + Translation = "Hola", + Transcription = "həˈloʊ", + Example = "Hola, ¿cómo estás?", + TranslationOfExample = "Hello, how are you?" + }; + + _translatorMock.Setup(t => t.TranslateAsync( + phrase, expectedModel, language, It.IsAny())) + .ReturnsAsync(translationInfoModel); + + // Act + var result = await _service.TranslateAsync(phrase, model, language, CancellationToken.None); + + // Assert + result.ShouldNotBeNull(); + result.Success.ShouldBeTrue(); + + _translatorMock.Verify(t => t.TranslateAsync(phrase, expectedModel, language, It.IsAny()), Times.Once); + } + + [Test] + public async Task TranslateAsync_WithNullLanguage_UsesEnglishLanguage() + { + // Arrange + string phrase = "Hello"; + string model = "gpt-4o"; + string? language = null; + string expectedLanguage = AllowedLanguages.English; + + var translationInfoModel = new TranslationInfoModel + { + Phrase = phrase, + Translation = "Hola", + Transcription = "həˈloʊ", + Example = "Hola, ¿cómo estás?", + TranslationOfExample = "Hello, how are you?" + }; + + _translatorMock.Setup(t => t.TranslateAsync( + phrase, model, expectedLanguage, It.IsAny())) + .ReturnsAsync(translationInfoModel); + + // Act + var result = await _service.TranslateAsync(phrase, model, language, CancellationToken.None); + + // Assert + result.ShouldNotBeNull(); + result.Success.ShouldBeTrue(); + _translatorMock.Verify(t => t.TranslateAsync(phrase, model, expectedLanguage, It.IsAny()), Times.Once); + } + + [Test] + public async Task TranslateAsync_WhenTranslatorThrowsException_ReturnsFailureResponse() + { + // Arrange + string phrase = "Hello"; + string model = "gpt-4o"; + string language = "Spanish"; + string errorMessage = "Translation error"; + + _translatorMock.Setup(t => t.TranslateAsync( + phrase, model, language, It.IsAny())) + .ThrowsAsync(new TranslationException(errorMessage)); + + // Act + var result = await _service.TranslateAsync(phrase, model, language, CancellationToken.None); + + // Assert + result.ShouldNotBeNull(); + result.Success.ShouldBeFalse(); + result.Response.ShouldBeNull(); + result.Errors.ShouldNotBeNull(); + result.Errors.ShouldContain(e => e.Contains(errorMessage)); + } + + [Test] + public async Task TranslateAsync_WhenTranslatorReturnsInvalidResponse_ReturnsFailureResponse() + { + // Arrange + string phrase = "Hello"; + string model = "gpt-4o"; + string language = "Spanish"; + + var translationInfoModel = new TranslationInfoModel + { + Phrase = "Different", + Translation = "Hola", + Transcription = "həˈloʊ", + Example = "Hola, ¿cómo estás?", + TranslationOfExample = "Hello, how are you?" + }; + + _translatorMock.Setup(t => t.TranslateAsync( + phrase, model, language, It.IsAny())) + .ReturnsAsync(translationInfoModel); + + // Act + var result = await _service.TranslateAsync(phrase, model, language, CancellationToken.None); + + // Assert + result.ShouldNotBeNull(); + result.Success.ShouldBeFalse(); + result.Response.ShouldBeNull(); + result.Errors.ShouldNotBeNull(); + } + } +} \ No newline at end of file diff --git a/Diksy.WebApi/Diksy.WebApi.csproj b/Diksy.WebApi/Diksy.WebApi.csproj index 4fdf453..839a1bb 100644 --- a/Diksy.WebApi/Diksy.WebApi.csproj +++ b/Diksy.WebApi/Diksy.WebApi.csproj @@ -19,4 +19,10 @@ + + + <_Parameter1>$(AssemblyName).UnitTests + + + diff --git a/Diksy.sln b/Diksy.sln index a0b6682..cd2b630 100644 --- a/Diksy.sln +++ b/Diksy.sln @@ -1,5 +1,4 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 +Microsoft Visual Studio Solution File, Format Version 12.00 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Diksy.Translation.OpenAI", "Diksy.Translation.OpenAI\Diksy.Translation.OpenAI.csproj", "{370F8BB4-C7D7-4378-B531-E27E00B29E56}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Diksy.Translation", "Diksy.Translation\Diksy.Translation.csproj", "{872D387E-5B3E-473D-BC16-44915DA9E150}" @@ -12,6 +11,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{F6BC499B EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Diksy.Translation.OpenAI.UnitTests", "Diksy.Translation.OpenAI.UnitTests\Diksy.Translation.OpenAI.UnitTests.csproj", "{F28D3B52-BF08-4B66-9B63-CA379F30D507}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Diksy.WebApi.UnitTests", "Diksy.WebApi.UnitTests\Diksy.WebApi.UnitTests.csproj", "{1C5A05F3-98D9-4C40-B05E-756C5567A275}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -34,6 +35,10 @@ Global {F28D3B52-BF08-4B66-9B63-CA379F30D507}.Debug|Any CPU.Build.0 = Debug|Any CPU {F28D3B52-BF08-4B66-9B63-CA379F30D507}.Release|Any CPU.ActiveCfg = Release|Any CPU {F28D3B52-BF08-4B66-9B63-CA379F30D507}.Release|Any CPU.Build.0 = Release|Any CPU + {1C5A05F3-98D9-4C40-B05E-756C5567A275}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1C5A05F3-98D9-4C40-B05E-756C5567A275}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1C5A05F3-98D9-4C40-B05E-756C5567A275}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1C5A05F3-98D9-4C40-B05E-756C5567A275}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -43,5 +48,6 @@ Global {872D387E-5B3E-473D-BC16-44915DA9E150} = {BF89416C-4B37-4712-8B6B-18016A4CF249} {9957D946-467B-4879-B2AF-36C384AB31F9} = {BF89416C-4B37-4712-8B6B-18016A4CF249} {F28D3B52-BF08-4B66-9B63-CA379F30D507} = {F6BC499B-DC93-4606-9B50-1FC816FC42BF} + {1C5A05F3-98D9-4C40-B05E-756C5567A275} = {F6BC499B-DC93-4606-9B50-1FC816FC42BF} EndGlobalSection EndGlobal From 8ad8e0781e49672f876d5bf6f33bb8026bca0ee5 Mon Sep 17 00:00:00 2001 From: Piotr Kantorowicz Date: Sat, 29 Mar 2025 15:17:35 +0100 Subject: [PATCH 6/7] Implement rate limiting for translation requests and enhance error handling - Added rate limiting to the TranslationController to restrict translation requests to 20 per minute. - Updated TranslationService to throw an exception if the translation of the example is null or empty. - Refactored unit tests for TranslationController and TranslationService to ensure proper handling of rate limiting and error scenarios. --- .../Controllers/TranslationControllerTests.cs | 76 +++++++------------ .../Services/TranslationServiceTests.cs | 58 ++++++++------ .../Controllers/TranslationController.cs | 3 + Diksy.WebApi/Program.cs | 13 ++++ Diksy.WebApi/Services/TranslationService.cs | 5 ++ 5 files changed, 83 insertions(+), 72 deletions(-) diff --git a/Diksy.WebApi.UnitTests/Controllers/TranslationControllerTests.cs b/Diksy.WebApi.UnitTests/Controllers/TranslationControllerTests.cs index a531a9a..3e5eafb 100644 --- a/Diksy.WebApi.UnitTests/Controllers/TranslationControllerTests.cs +++ b/Diksy.WebApi.UnitTests/Controllers/TranslationControllerTests.cs @@ -8,40 +8,34 @@ using Moq; using NUnit.Framework; using Shouldly; -using System.Net; namespace Diksy.WebApi.UnitTests.Controllers { [TestFixture] public class TranslationControllerTests { - private Mock _translationServiceMock; - private Mock> _loggerMock; - private TranslationController _controller; - [SetUp] public void SetUp() { _translationServiceMock = new Mock(); _loggerMock = new Mock>(); - + _controller = new TranslationController( translationService: _translationServiceMock.Object, logger: _loggerMock.Object); } + private Mock _translationServiceMock; + private Mock> _loggerMock; + private TranslationController _controller; + [Test] public async Task Translate_WithValidRequest_ReturnsOkResult() { // Arrange - var request = new TranslationRequest - { - Phrase = "Hello", - Model = "gpt-4o", - Language = "Spanish" - }; + TranslationRequest request = new() { Phrase = "Hello", Model = "gpt-4o", Language = "Spanish" }; - var translationInfo = new TranslationInfo + TranslationInfo translationInfo = new() { Phrase = "Hello", Translation = "Hola", @@ -50,20 +44,20 @@ public async Task Translate_WithValidRequest_ReturnsOkResult() TranslationOfExample = "Hello, how are you?" }; - var expectedResponse = TranslationResponse.SuccessResponse(translationInfo); + TranslationResponse expectedResponse = TranslationResponse.SuccessResponse(translationInfo); _translationServiceMock.Setup(s => s.TranslateAsync( - request.Phrase, request.Model, request.Language, It.IsAny())) + request.Phrase, request.Model, request.Language, It.IsAny())) .ReturnsAsync(expectedResponse); // Act - var result = await _controller.Translate(request); + IActionResult result = await _controller.Translate(request); // Assert - var okResult = result.ShouldBeOfType(); + OkObjectResult okResult = result.ShouldBeOfType(); okResult.StatusCode.ShouldBe(StatusCodes.Status200OK); - - var responseValue = okResult.Value.ShouldBeOfType(); + + TranslationResponse responseValue = okResult.Value.ShouldBeOfType(); responseValue.Success.ShouldBeTrue(); responseValue.Response.ShouldNotBeNull(); responseValue.Response.Phrase.ShouldBe("Hello"); @@ -77,23 +71,20 @@ public async Task Translate_WithValidRequest_ReturnsOkResult() public async Task Translate_WithInvalidRequest_ReturnsBadRequest() { // Arrange - var request = new TranslationRequest - { - Phrase = "Hi", - Model = "gpt-4o", - Language = "Spanish" - }; + TranslationRequest request = new() { Phrase = "Hi", Model = "gpt-4o", Language = "Spanish" }; - _controller.ModelState.AddModelError("Phrase", "The field Phrase must be a string with a minimum length of 3 and a maximum length of 30."); + _controller.ModelState.AddModelError(key: "Phrase", + errorMessage: + "The field Phrase must be a string with a minimum length of 3 and a maximum length of 30."); // Act - var result = await _controller.Translate(request); + IActionResult result = await _controller.Translate(request); // Assert - var badRequestResult = result.ShouldBeOfType(); + BadRequestObjectResult badRequestResult = result.ShouldBeOfType(); badRequestResult.StatusCode.ShouldBe(StatusCodes.Status400BadRequest); - - var problemDetails = badRequestResult.Value.ShouldBeOfType(); + + ApiProblemDetails problemDetails = badRequestResult.Value.ShouldBeOfType(); problemDetails.Title.ShouldBe("Validation Failed"); problemDetails.Status.ShouldBe(StatusCodes.Status400BadRequest); problemDetails?.Errors?.ShouldContainKey("Phrase"); @@ -103,33 +94,24 @@ public async Task Translate_WithInvalidRequest_ReturnsBadRequest() public async Task Translate_WhenTranslationFails_ReturnsInternalServerError() { // Arrange - var request = new TranslationRequest - { - Phrase = "Hello", - Model = "gpt-4o", - Language = "Spanish" - }; + TranslationRequest request = new() { Phrase = "Hello", Model = "gpt-4o", Language = "Spanish" }; - var failedResponse = new TranslationResponse - { - Success = false, - Errors = ["Translation service error"] - }; + TranslationResponse failedResponse = new() { Success = false, Errors = ["Translation service error"] }; _translationServiceMock.Setup(s => s.TranslateAsync( - request.Phrase, request.Model, request.Language, It.IsAny())) + request.Phrase, request.Model, request.Language, It.IsAny())) .ReturnsAsync(failedResponse); // Act - var result = await _controller.Translate(request); + IActionResult result = await _controller.Translate(request); // Assert - var statusCodeResult = result.ShouldBeOfType(); + ObjectResult statusCodeResult = result.ShouldBeOfType(); statusCodeResult.StatusCode.ShouldBe(StatusCodes.Status500InternalServerError); - - var problemDetails = statusCodeResult.Value.ShouldBeOfType(); + + ApiProblemDetails problemDetails = statusCodeResult.Value.ShouldBeOfType(); problemDetails.Title.ShouldBe("Translation Error"); problemDetails.Status.ShouldBe(StatusCodes.Status500InternalServerError); } } -} \ No newline at end of file +} \ No newline at end of file diff --git a/Diksy.WebApi.UnitTests/Services/TranslationServiceTests.cs b/Diksy.WebApi.UnitTests/Services/TranslationServiceTests.cs index 1c1f484..9b196f5 100644 --- a/Diksy.WebApi.UnitTests/Services/TranslationServiceTests.cs +++ b/Diksy.WebApi.UnitTests/Services/TranslationServiceTests.cs @@ -1,6 +1,5 @@ using Diksy.Translation; using Diksy.Translation.Exceptions; -using Diksy.Translation.Models; using Diksy.Translation.OpenAI; using Diksy.Translation.Services; using Diksy.WebApi.Models.Translation; @@ -16,11 +15,6 @@ namespace Diksy.WebApi.UnitTests.Services [TestFixture] public class TranslationServiceTests { - private Mock _translatorMock; - private Mock> _loggerMock; - private OpenAiSettings _openAiSettings; - private TranslationService _service; - [SetUp] public void SetUp() { @@ -34,6 +28,11 @@ public void SetUp() openAiSettings: _openAiSettings); } + private Mock _translatorMock; + private Mock> _loggerMock; + private OpenAiSettings _openAiSettings; + private TranslationService _service; + [Test] public async Task TranslateAsync_WithValidInput_ReturnsSuccessfulResponse() { @@ -42,7 +41,7 @@ public async Task TranslateAsync_WithValidInput_ReturnsSuccessfulResponse() string model = "gpt-4o"; string language = "Spanish"; - var translationInfoModel = new TranslationInfoModel + TranslationInfoModel translationInfoModel = new() { Phrase = phrase, Translation = "Hola", @@ -52,11 +51,12 @@ public async Task TranslateAsync_WithValidInput_ReturnsSuccessfulResponse() }; _translatorMock.Setup(t => t.TranslateAsync( - phrase, model, language, It.IsAny())) + phrase, model, language, It.IsAny())) .ReturnsAsync(translationInfoModel); // Act - var result = await _service.TranslateAsync(phrase, model, language, CancellationToken.None); + TranslationResponse result = await _service.TranslateAsync(phrase: phrase, model: model, language: language, + cancellationToken: CancellationToken.None); // Assert result.ShouldNotBeNull(); @@ -78,7 +78,7 @@ public async Task TranslateAsync_WithNullModel_UsesDefaultModel() string language = "Spanish"; string expectedModel = _openAiSettings?.DefaultModel ?? AllowedModels.Gpt4O; - var translationInfoModel = new TranslationInfoModel + TranslationInfoModel translationInfoModel = new() { Phrase = phrase, Translation = "Hola", @@ -88,17 +88,20 @@ public async Task TranslateAsync_WithNullModel_UsesDefaultModel() }; _translatorMock.Setup(t => t.TranslateAsync( - phrase, expectedModel, language, It.IsAny())) + phrase, expectedModel, language, It.IsAny())) .ReturnsAsync(translationInfoModel); // Act - var result = await _service.TranslateAsync(phrase, model, language, CancellationToken.None); + TranslationResponse result = await _service.TranslateAsync(phrase: phrase, model: model, language: language, + cancellationToken: CancellationToken.None); // Assert result.ShouldNotBeNull(); result.Success.ShouldBeTrue(); - - _translatorMock.Verify(t => t.TranslateAsync(phrase, expectedModel, language, It.IsAny()), Times.Once); + + _translatorMock.Verify( + expression: t => t.TranslateAsync(phrase, expectedModel, language, It.IsAny()), + times: Times.Once); } [Test] @@ -110,7 +113,7 @@ public async Task TranslateAsync_WithNullLanguage_UsesEnglishLanguage() string? language = null; string expectedLanguage = AllowedLanguages.English; - var translationInfoModel = new TranslationInfoModel + TranslationInfoModel translationInfoModel = new() { Phrase = phrase, Translation = "Hola", @@ -120,16 +123,19 @@ public async Task TranslateAsync_WithNullLanguage_UsesEnglishLanguage() }; _translatorMock.Setup(t => t.TranslateAsync( - phrase, model, expectedLanguage, It.IsAny())) + phrase, model, expectedLanguage, It.IsAny())) .ReturnsAsync(translationInfoModel); // Act - var result = await _service.TranslateAsync(phrase, model, language, CancellationToken.None); + TranslationResponse result = await _service.TranslateAsync(phrase: phrase, model: model, language: language, + cancellationToken: CancellationToken.None); // Assert result.ShouldNotBeNull(); result.Success.ShouldBeTrue(); - _translatorMock.Verify(t => t.TranslateAsync(phrase, model, expectedLanguage, It.IsAny()), Times.Once); + _translatorMock.Verify( + expression: t => t.TranslateAsync(phrase, model, expectedLanguage, It.IsAny()), + times: Times.Once); } [Test] @@ -142,11 +148,12 @@ public async Task TranslateAsync_WhenTranslatorThrowsException_ReturnsFailureRes string errorMessage = "Translation error"; _translatorMock.Setup(t => t.TranslateAsync( - phrase, model, language, It.IsAny())) + phrase, model, language, It.IsAny())) .ThrowsAsync(new TranslationException(errorMessage)); // Act - var result = await _service.TranslateAsync(phrase, model, language, CancellationToken.None); + TranslationResponse result = await _service.TranslateAsync(phrase: phrase, model: model, language: language, + cancellationToken: CancellationToken.None); // Assert result.ShouldNotBeNull(); @@ -164,9 +171,9 @@ public async Task TranslateAsync_WhenTranslatorReturnsInvalidResponse_ReturnsFai string model = "gpt-4o"; string language = "Spanish"; - var translationInfoModel = new TranslationInfoModel + TranslationInfoModel translationInfoModel = new() { - Phrase = "Different", + Phrase = "Different", Translation = "Hola", Transcription = "həˈloʊ", Example = "Hola, ¿cómo estás?", @@ -174,11 +181,12 @@ public async Task TranslateAsync_WhenTranslatorReturnsInvalidResponse_ReturnsFai }; _translatorMock.Setup(t => t.TranslateAsync( - phrase, model, language, It.IsAny())) + phrase, model, language, It.IsAny())) .ReturnsAsync(translationInfoModel); // Act - var result = await _service.TranslateAsync(phrase, model, language, CancellationToken.None); + TranslationResponse result = await _service.TranslateAsync(phrase: phrase, model: model, language: language, + cancellationToken: CancellationToken.None); // Assert result.ShouldNotBeNull(); @@ -187,4 +195,4 @@ public async Task TranslateAsync_WhenTranslatorReturnsInvalidResponse_ReturnsFai result.Errors.ShouldNotBeNull(); } } -} \ No newline at end of file +} \ No newline at end of file diff --git a/Diksy.WebApi/Controllers/TranslationController.cs b/Diksy.WebApi/Controllers/TranslationController.cs index e2bd317..b8d7bd8 100644 --- a/Diksy.WebApi/Controllers/TranslationController.cs +++ b/Diksy.WebApi/Controllers/TranslationController.cs @@ -2,6 +2,7 @@ using Diksy.WebApi.Models.Translation; using Diksy.WebApi.Services; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; namespace Diksy.WebApi.Controllers { @@ -10,8 +11,10 @@ namespace Diksy.WebApi.Controllers /// [ApiController] [Route("api/[controller]")] + [EnableRateLimiting("translation")] [ProducesResponseType(type: typeof(ApiProblemDetails), statusCode: StatusCodes.Status400BadRequest)] [ProducesResponseType(type: typeof(ApiProblemDetails), statusCode: StatusCodes.Status500InternalServerError)] + [ProducesResponseType(type: typeof(ApiProblemDetails), statusCode: StatusCodes.Status429TooManyRequests)] public class TranslationController(ITranslationService translationService, ILogger logger) : ControllerBase { diff --git a/Diksy.WebApi/Program.cs b/Diksy.WebApi/Program.cs index c98b3af..c06cae2 100644 --- a/Diksy.WebApi/Program.cs +++ b/Diksy.WebApi/Program.cs @@ -1,7 +1,9 @@ using Diksy.Translation.OpenAI; using Diksy.Translation.OpenAI.Extensions; using Diksy.WebApi.Services; +using Microsoft.AspNetCore.RateLimiting; using NSwag; +using System.Threading.RateLimiting; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); @@ -19,6 +21,17 @@ }; }); +builder.Services.AddRateLimiter(options => +{ + options.AddFixedWindowLimiter(policyName: "translation", configureOptions: config => + { + config.Window = TimeSpan.FromMinutes(1); + config.PermitLimit = 20; + config.QueueLimit = 10; + config.QueueProcessingOrder = QueueProcessingOrder.OldestFirst; + }); +}); + OpenAiSettings openAiSettings = builder.Configuration.GetSection("OpenAI").Get() ?? new OpenAiSettings( ApiKey: builder.Configuration["OpenAI:ApiKey"] ?? diff --git a/Diksy.WebApi/Services/TranslationService.cs b/Diksy.WebApi/Services/TranslationService.cs index 38ada34..f85c3e9 100644 --- a/Diksy.WebApi/Services/TranslationService.cs +++ b/Diksy.WebApi/Services/TranslationService.cs @@ -75,6 +75,11 @@ private static void SanitizeTranslationResponse(string phrase, TranslationInfoDt { throw new TranslationException("Example is null or empty"); } + + if (string.IsNullOrEmpty(translation.TranslationOfExample)) + { + throw new TranslationException("Translation of example is null or empty"); + } } } } \ No newline at end of file From f0c87e2d10ae730c258dba3f2d7669345d62aea0 Mon Sep 17 00:00:00 2001 From: Piotr Kantorowicz Date: Sun, 30 Mar 2025 06:02:55 +0200 Subject: [PATCH 7/7] Refactor TranslationService and update unit tests for TranslationController - Removed unnecessary whitespace in TranslationService to improve code readability. - Updated unit tests in TranslationControllerTests to verify translation service interactions, ensuring correct handling of translation requests and responses. --- .../Controllers/TranslationControllerTests.cs | 12 ++++++++++++ .../Diksy.WebApi.UnitTests.csproj | 14 +++++++------- Diksy.WebApi/Services/TranslationService.cs | 2 +- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/Diksy.WebApi.UnitTests/Controllers/TranslationControllerTests.cs b/Diksy.WebApi.UnitTests/Controllers/TranslationControllerTests.cs index 3e5eafb..ba0cddb 100644 --- a/Diksy.WebApi.UnitTests/Controllers/TranslationControllerTests.cs +++ b/Diksy.WebApi.UnitTests/Controllers/TranslationControllerTests.cs @@ -65,6 +65,10 @@ public async Task Translate_WithValidRequest_ReturnsOkResult() responseValue.Response.Transcription.ShouldBe("həˈloʊ"); responseValue.Response.Example.ShouldBe("Hola, ¿cómo estás?"); responseValue.Response.TranslationOfExample.ShouldBe("Hello, how are you?"); + + _translationServiceMock.Verify(expression: s => s.TranslateAsync( + request.Phrase, request.Model, request.Language, It.IsAny()), + times: Times.Once); } [Test] @@ -88,6 +92,10 @@ public async Task Translate_WithInvalidRequest_ReturnsBadRequest() problemDetails.Title.ShouldBe("Validation Failed"); problemDetails.Status.ShouldBe(StatusCodes.Status400BadRequest); problemDetails?.Errors?.ShouldContainKey("Phrase"); + + _translationServiceMock.Verify(expression: s => s.TranslateAsync( + request.Phrase, request.Model, request.Language, It.IsAny()), + times: Times.Never); } [Test] @@ -112,6 +120,10 @@ public async Task Translate_WhenTranslationFails_ReturnsInternalServerError() ApiProblemDetails problemDetails = statusCodeResult.Value.ShouldBeOfType(); problemDetails.Title.ShouldBe("Translation Error"); problemDetails.Status.ShouldBe(StatusCodes.Status500InternalServerError); + + _translationServiceMock.Verify(expression: s => s.TranslateAsync( + request.Phrase, request.Model, request.Language, It.IsAny()), + times: Times.Once); } } } \ No newline at end of file diff --git a/Diksy.WebApi.UnitTests/Diksy.WebApi.UnitTests.csproj b/Diksy.WebApi.UnitTests/Diksy.WebApi.UnitTests.csproj index 234f80b..4e39ba8 100644 --- a/Diksy.WebApi.UnitTests/Diksy.WebApi.UnitTests.csproj +++ b/Diksy.WebApi.UnitTests/Diksy.WebApi.UnitTests.csproj @@ -10,16 +10,16 @@ - - - - + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -27,7 +27,7 @@ - + \ No newline at end of file diff --git a/Diksy.WebApi/Services/TranslationService.cs b/Diksy.WebApi/Services/TranslationService.cs index f85c3e9..fd09ff0 100644 --- a/Diksy.WebApi/Services/TranslationService.cs +++ b/Diksy.WebApi/Services/TranslationService.cs @@ -75,7 +75,7 @@ private static void SanitizeTranslationResponse(string phrase, TranslationInfoDt { throw new TranslationException("Example is null or empty"); } - + if (string.IsNullOrEmpty(translation.TranslationOfExample)) { throw new TranslationException("Translation of example is null or empty");