fOD~l6nt&Q>qMVR2F3S|f?eH_b%
fIErtgNQ2K74PZ-Sst_YE^CKW-FqKi@pDOSNPt&_I
literal 0
HcmV?d00001
diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
new file mode 100644
index 0000000..517f9b3
--- /dev/null
+++ b/.config/dotnet-tools.json
@@ -0,0 +1,5 @@
+{
+ "version": 1,
+ "isRoot": true,
+ "tools": {}
+}
\ No newline at end of file
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..4a9952c
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,364 @@
+root = true
+
+# All files
+[*]
+indent_style = space
+
+# Xml files
+[*.xml]
+indent_size = 2
+
+# C# files
+[*.cs]
+
+#### Core EditorConfig Options ####
+
+# Indentation and spacing
+indent_size = 4
+tab_width = 4
+
+# New line preferences
+end_of_line = crlf
+insert_final_newline = false
+
+#### .NET Coding Conventions ####
+[*.{cs,vb}]
+
+# Organize usings
+dotnet_separate_import_directive_groups = true
+dotnet_sort_system_directives_first = true
+file_header_template = unset
+
+# this. and Me. preferences
+dotnet_style_qualification_for_event = false:silent
+dotnet_style_qualification_for_field = false:silent
+dotnet_style_qualification_for_method = false:silent
+dotnet_style_qualification_for_property = false:silent
+
+# Language keywords vs BCL types preferences
+dotnet_style_predefined_type_for_locals_parameters_members = true:silent
+dotnet_style_predefined_type_for_member_access = true:silent
+
+# Parentheses preferences
+dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent
+dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent
+dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent
+dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent
+
+# Modifier preferences
+dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent
+
+# Expression-level preferences
+dotnet_style_coalesce_expression = true:suggestion
+dotnet_style_collection_initializer = true:suggestion
+dotnet_style_explicit_tuple_names = true:suggestion
+dotnet_style_null_propagation = true:suggestion
+dotnet_style_object_initializer = true:suggestion
+dotnet_style_operator_placement_when_wrapping = beginning_of_line
+dotnet_style_prefer_auto_properties = true:suggestion
+dotnet_style_prefer_compound_assignment = true:suggestion
+dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion
+dotnet_style_prefer_conditional_expression_over_return = true:suggestion
+dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
+dotnet_style_prefer_inferred_tuple_names = true:suggestion
+dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
+dotnet_style_prefer_simplified_boolean_expressions = true:suggestion
+dotnet_style_prefer_simplified_interpolation = true:suggestion
+
+# Field preferences
+dotnet_style_readonly_field = true:warning
+
+# Parameter preferences
+dotnet_code_quality_unused_parameters = all:suggestion
+
+# Suppression preferences
+dotnet_remove_unnecessary_suppression_exclusions = none
+
+#### C# Coding Conventions ####
+[*.cs]
+
+# var preferences
+csharp_style_var_elsewhere = false:silent
+csharp_style_var_for_built_in_types = false:silent
+csharp_style_var_when_type_is_apparent = false:silent
+
+# Expression-bodied members
+csharp_style_expression_bodied_accessors = true:silent
+csharp_style_expression_bodied_constructors = false:silent
+csharp_style_expression_bodied_indexers = true:silent
+csharp_style_expression_bodied_lambdas = true:suggestion
+csharp_style_expression_bodied_local_functions = false:silent
+csharp_style_expression_bodied_methods = false:silent
+csharp_style_expression_bodied_operators = false:silent
+csharp_style_expression_bodied_properties = true:silent
+
+# Pattern matching preferences
+csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
+csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
+csharp_style_prefer_not_pattern = true:suggestion
+csharp_style_prefer_pattern_matching = true:silent
+csharp_style_prefer_switch_expression = true:suggestion
+
+# Null-checking preferences
+csharp_style_conditional_delegate_call = true:suggestion
+
+# Modifier preferences
+csharp_prefer_static_local_function = true:warning
+csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:silent
+
+# Code-block preferences
+csharp_prefer_braces = true:silent
+csharp_prefer_simple_using_statement = true:suggestion
+
+# Expression-level preferences
+csharp_prefer_simple_default_expression = true:suggestion
+csharp_style_deconstructed_variable_declaration = true:suggestion
+csharp_style_inlined_variable_declaration = true:suggestion
+csharp_style_pattern_local_over_anonymous_function = true:suggestion
+csharp_style_prefer_index_operator = true:suggestion
+csharp_style_prefer_range_operator = true:suggestion
+csharp_style_throw_expression = true:suggestion
+csharp_style_unused_value_assignment_preference = discard_variable:suggestion
+csharp_style_unused_value_expression_statement_preference = discard_variable:silent
+
+# 'using' directive preferences
+csharp_using_directive_placement = outside_namespace:silent
+
+#### 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 ####
+[*.{cs,vb}]
+
+# Naming rules
+
+dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.severity = suggestion
+dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.symbols = types_and_namespaces
+dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.style = pascalcase
+
+dotnet_naming_rule.interfaces_should_be_ipascalcase.severity = suggestion
+dotnet_naming_rule.interfaces_should_be_ipascalcase.symbols = interfaces
+dotnet_naming_rule.interfaces_should_be_ipascalcase.style = ipascalcase
+
+dotnet_naming_rule.type_parameters_should_be_tpascalcase.severity = suggestion
+dotnet_naming_rule.type_parameters_should_be_tpascalcase.symbols = type_parameters
+dotnet_naming_rule.type_parameters_should_be_tpascalcase.style = tpascalcase
+
+dotnet_naming_rule.methods_should_be_pascalcase.severity = suggestion
+dotnet_naming_rule.methods_should_be_pascalcase.symbols = methods
+dotnet_naming_rule.methods_should_be_pascalcase.style = pascalcase
+
+dotnet_naming_rule.properties_should_be_pascalcase.severity = suggestion
+dotnet_naming_rule.properties_should_be_pascalcase.symbols = properties
+dotnet_naming_rule.properties_should_be_pascalcase.style = pascalcase
+
+dotnet_naming_rule.events_should_be_pascalcase.severity = suggestion
+dotnet_naming_rule.events_should_be_pascalcase.symbols = events
+dotnet_naming_rule.events_should_be_pascalcase.style = pascalcase
+
+dotnet_naming_rule.local_variables_should_be_camelcase.severity = suggestion
+dotnet_naming_rule.local_variables_should_be_camelcase.symbols = local_variables
+dotnet_naming_rule.local_variables_should_be_camelcase.style = camelcase
+
+dotnet_naming_rule.local_constants_should_be_camelcase.severity = suggestion
+dotnet_naming_rule.local_constants_should_be_camelcase.symbols = local_constants
+dotnet_naming_rule.local_constants_should_be_camelcase.style = camelcase
+
+dotnet_naming_rule.parameters_should_be_camelcase.severity = suggestion
+dotnet_naming_rule.parameters_should_be_camelcase.symbols = parameters
+dotnet_naming_rule.parameters_should_be_camelcase.style = camelcase
+
+dotnet_naming_rule.public_fields_should_be_pascalcase.severity = suggestion
+dotnet_naming_rule.public_fields_should_be_pascalcase.symbols = public_fields
+dotnet_naming_rule.public_fields_should_be_pascalcase.style = pascalcase
+
+dotnet_naming_rule.private_fields_should_be__camelcase.severity = suggestion
+dotnet_naming_rule.private_fields_should_be__camelcase.symbols = private_fields
+dotnet_naming_rule.private_fields_should_be__camelcase.style = _camelcase
+
+dotnet_naming_rule.private_static_fields_should_be_s_camelcase.severity = suggestion
+dotnet_naming_rule.private_static_fields_should_be_s_camelcase.symbols = private_static_fields
+dotnet_naming_rule.private_static_fields_should_be_s_camelcase.style = s_camelcase
+
+dotnet_naming_rule.public_constant_fields_should_be_pascalcase.severity = suggestion
+dotnet_naming_rule.public_constant_fields_should_be_pascalcase.symbols = public_constant_fields
+dotnet_naming_rule.public_constant_fields_should_be_pascalcase.style = pascalcase
+
+dotnet_naming_rule.private_constant_fields_should_be_pascalcase.severity = suggestion
+dotnet_naming_rule.private_constant_fields_should_be_pascalcase.symbols = private_constant_fields
+dotnet_naming_rule.private_constant_fields_should_be_pascalcase.style = pascalcase
+
+dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.severity = suggestion
+dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.symbols = public_static_readonly_fields
+dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.style = pascalcase
+
+dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.severity = suggestion
+dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.symbols = private_static_readonly_fields
+dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.style = pascalcase
+
+dotnet_naming_rule.enums_should_be_pascalcase.severity = suggestion
+dotnet_naming_rule.enums_should_be_pascalcase.symbols = enums
+dotnet_naming_rule.enums_should_be_pascalcase.style = pascalcase
+
+dotnet_naming_rule.local_functions_should_be_pascalcase.severity = suggestion
+dotnet_naming_rule.local_functions_should_be_pascalcase.symbols = local_functions
+dotnet_naming_rule.local_functions_should_be_pascalcase.style = pascalcase
+
+dotnet_naming_rule.non_field_members_should_be_pascalcase.severity = suggestion
+dotnet_naming_rule.non_field_members_should_be_pascalcase.symbols = non_field_members
+dotnet_naming_rule.non_field_members_should_be_pascalcase.style = pascalcase
+
+# Symbol specifications
+
+dotnet_naming_symbols.interfaces.applicable_kinds = interface
+dotnet_naming_symbols.interfaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.interfaces.required_modifiers =
+
+dotnet_naming_symbols.enums.applicable_kinds = enum
+dotnet_naming_symbols.enums.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.enums.required_modifiers =
+
+dotnet_naming_symbols.events.applicable_kinds = event
+dotnet_naming_symbols.events.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.events.required_modifiers =
+
+dotnet_naming_symbols.methods.applicable_kinds = method
+dotnet_naming_symbols.methods.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.methods.required_modifiers =
+
+dotnet_naming_symbols.properties.applicable_kinds = property
+dotnet_naming_symbols.properties.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.properties.required_modifiers =
+
+dotnet_naming_symbols.public_fields.applicable_kinds = field
+dotnet_naming_symbols.public_fields.applicable_accessibilities = public, internal
+dotnet_naming_symbols.public_fields.required_modifiers =
+
+dotnet_naming_symbols.private_fields.applicable_kinds = field
+dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
+dotnet_naming_symbols.private_fields.required_modifiers =
+
+dotnet_naming_symbols.private_static_fields.applicable_kinds = field
+dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
+dotnet_naming_symbols.private_static_fields.required_modifiers = static
+
+dotnet_naming_symbols.types_and_namespaces.applicable_kinds = namespace, class, struct, interface, enum
+dotnet_naming_symbols.types_and_namespaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.types_and_namespaces.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 =
+
+dotnet_naming_symbols.type_parameters.applicable_kinds = namespace
+dotnet_naming_symbols.type_parameters.applicable_accessibilities = *
+dotnet_naming_symbols.type_parameters.required_modifiers =
+
+dotnet_naming_symbols.private_constant_fields.applicable_kinds = field
+dotnet_naming_symbols.private_constant_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
+dotnet_naming_symbols.private_constant_fields.required_modifiers = const
+
+dotnet_naming_symbols.local_variables.applicable_kinds = local
+dotnet_naming_symbols.local_variables.applicable_accessibilities = local
+dotnet_naming_symbols.local_variables.required_modifiers =
+
+dotnet_naming_symbols.local_constants.applicable_kinds = local
+dotnet_naming_symbols.local_constants.applicable_accessibilities = local
+dotnet_naming_symbols.local_constants.required_modifiers = const
+
+dotnet_naming_symbols.parameters.applicable_kinds = parameter
+dotnet_naming_symbols.parameters.applicable_accessibilities = *
+dotnet_naming_symbols.parameters.required_modifiers =
+
+dotnet_naming_symbols.public_constant_fields.applicable_kinds = field
+dotnet_naming_symbols.public_constant_fields.applicable_accessibilities = public, internal
+dotnet_naming_symbols.public_constant_fields.required_modifiers = const
+
+dotnet_naming_symbols.public_static_readonly_fields.applicable_kinds = field
+dotnet_naming_symbols.public_static_readonly_fields.applicable_accessibilities = public, internal
+dotnet_naming_symbols.public_static_readonly_fields.required_modifiers = readonly, static
+
+dotnet_naming_symbols.private_static_readonly_fields.applicable_kinds = field
+dotnet_naming_symbols.private_static_readonly_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
+dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = readonly, static
+
+dotnet_naming_symbols.local_functions.applicable_kinds = local_function
+dotnet_naming_symbols.local_functions.applicable_accessibilities = *
+dotnet_naming_symbols.local_functions.required_modifiers =
+
+# Naming styles
+
+dotnet_naming_style.pascalcase.required_prefix =
+dotnet_naming_style.pascalcase.required_suffix =
+dotnet_naming_style.pascalcase.word_separator =
+dotnet_naming_style.pascalcase.capitalization = pascal_case
+
+dotnet_naming_style.ipascalcase.required_prefix = I
+dotnet_naming_style.ipascalcase.required_suffix =
+dotnet_naming_style.ipascalcase.word_separator =
+dotnet_naming_style.ipascalcase.capitalization = pascal_case
+
+dotnet_naming_style.tpascalcase.required_prefix = T
+dotnet_naming_style.tpascalcase.required_suffix =
+dotnet_naming_style.tpascalcase.word_separator =
+dotnet_naming_style.tpascalcase.capitalization = pascal_case
+
+dotnet_naming_style._camelcase.required_prefix = _
+dotnet_naming_style._camelcase.required_suffix =
+dotnet_naming_style._camelcase.word_separator =
+dotnet_naming_style._camelcase.capitalization = camel_case
+
+dotnet_naming_style.camelcase.required_prefix =
+dotnet_naming_style.camelcase.required_suffix =
+dotnet_naming_style.camelcase.word_separator =
+dotnet_naming_style.camelcase.capitalization = camel_case
+
+dotnet_naming_style.s_camelcase.required_prefix = s_
+dotnet_naming_style.s_camelcase.required_suffix =
+dotnet_naming_style.s_camelcase.word_separator =
+dotnet_naming_style.s_camelcase.capitalization = camel_case
+
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 0000000..5c7247b
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,7 @@
+{
+ // Use IntelliSense to learn about possible attributes.
+ // Hover to view descriptions of existing attributes.
+ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
+ "version": "0.2.0",
+ "configurations": []
+}
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..ebda1eb
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,3 @@
+{
+ "dotnet.defaultSolution": "CleanApi.sln"
+}
\ No newline at end of file
diff --git a/CleanApi.sln b/CleanApi.sln
new file mode 100644
index 0000000..e7ee670
--- /dev/null
+++ b/CleanApi.sln
@@ -0,0 +1,67 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{B89C7703-FA6E-4F45-81D3-6591C0E97548}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Api", "src\Api\Api.csproj", "{21EE4D82-D963-4832-B621-B8B5138377B7}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Application", "src\Application\Application.csproj", "{A804F7DE-51BF-44CB-A2FA-680FC68571DB}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Domain", "src\Domain\Domain.csproj", "{3E8AD1A4-B7A2-4FD6-A438-0E709E54C5CA}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure", "src\Infrastructure\Infrastructure.csproj", "{662A350A-2580-4B96-A1E6-C8C742E60F78}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{09BA79D0-7A13-419E-A21E-C32B3E7EAE0C}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnitTests", "tests\UnitTests\UnitTests.csproj", "{3BE68B1A-FF8A-49C5-A787-6B26600D12C8}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{F1E8E655-4E70-45DD-9EBA-7CFF831E84A9}"
+ ProjectSection(SolutionItems) = preProject
+ Directory.Build.props = Directory.Build.props
+ .editorconfig = .editorconfig
+ global.json = global.json
+ .gitignore = .gitignore
+ Directory.Packages.props = Directory.Packages.props
+ .config\dotnet-tools.json = .config\dotnet-tools.json
+ EndProjectSection
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {21EE4D82-D963-4832-B621-B8B5138377B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {21EE4D82-D963-4832-B621-B8B5138377B7}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {21EE4D82-D963-4832-B621-B8B5138377B7}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {21EE4D82-D963-4832-B621-B8B5138377B7}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A804F7DE-51BF-44CB-A2FA-680FC68571DB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A804F7DE-51BF-44CB-A2FA-680FC68571DB}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A804F7DE-51BF-44CB-A2FA-680FC68571DB}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A804F7DE-51BF-44CB-A2FA-680FC68571DB}.Release|Any CPU.Build.0 = Release|Any CPU
+ {3E8AD1A4-B7A2-4FD6-A438-0E709E54C5CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {3E8AD1A4-B7A2-4FD6-A438-0E709E54C5CA}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {3E8AD1A4-B7A2-4FD6-A438-0E709E54C5CA}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {3E8AD1A4-B7A2-4FD6-A438-0E709E54C5CA}.Release|Any CPU.Build.0 = Release|Any CPU
+ {662A350A-2580-4B96-A1E6-C8C742E60F78}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {662A350A-2580-4B96-A1E6-C8C742E60F78}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {662A350A-2580-4B96-A1E6-C8C742E60F78}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {662A350A-2580-4B96-A1E6-C8C742E60F78}.Release|Any CPU.Build.0 = Release|Any CPU
+ {3BE68B1A-FF8A-49C5-A787-6B26600D12C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {3BE68B1A-FF8A-49C5-A787-6B26600D12C8}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {3BE68B1A-FF8A-49C5-A787-6B26600D12C8}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {3BE68B1A-FF8A-49C5-A787-6B26600D12C8}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {21EE4D82-D963-4832-B621-B8B5138377B7} = {B89C7703-FA6E-4F45-81D3-6591C0E97548}
+ {A804F7DE-51BF-44CB-A2FA-680FC68571DB} = {B89C7703-FA6E-4F45-81D3-6591C0E97548}
+ {3E8AD1A4-B7A2-4FD6-A438-0E709E54C5CA} = {B89C7703-FA6E-4F45-81D3-6591C0E97548}
+ {662A350A-2580-4B96-A1E6-C8C742E60F78} = {B89C7703-FA6E-4F45-81D3-6591C0E97548}
+ {3BE68B1A-FF8A-49C5-A787-6B26600D12C8} = {09BA79D0-7A13-419E-A21E-C32B3E7EAE0C}
+ EndGlobalSection
+EndGlobal
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..2b78872
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,10 @@
+
+
+
+ net8.0
+ true
+
+ enable
+ enable
+
+
\ No newline at end of file
diff --git a/Directory.Packages.props b/Directory.Packages.props
new file mode 100644
index 0000000..3121cd2
--- /dev/null
+++ b/Directory.Packages.props
@@ -0,0 +1,16 @@
+
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/global.json b/global.json
new file mode 100644
index 0000000..5c4e745
--- /dev/null
+++ b/global.json
@@ -0,0 +1,6 @@
+{
+ "sdk": {
+ "version": "8.0.100",
+ "rollForward": "latestFeature"
+ }
+}
\ No newline at end of file
diff --git a/src/.DS_Store b/src/.DS_Store
new file mode 100644
index 0000000000000000000000000000000000000000..84e94681384b6e237748307f35fa40cc9bbe9497
GIT binary patch
literal 6148
zcmeHK%}N6?5T4j-Q;N`oipPN0f~_bOysX>$0JK=RFGW|Ex`Ha`F${As@hPzL~sjZl!XLTGrUYr_Q-3NuFv=P-mg%ErSL
z6aB>@`gRE&NMHaVeEa?=(GmJSqG1w`C(Y)k*eI1Zw<@wCYw|vL(pfMGC)2SXj&8}i
z(mII>J&dm7LDsA9baXli<8&}G32}dbA$K=%+Si${r)hs|as%s-Rax!T_hz$ZL$#0Q
z4K-^WpR^n56peXR?j9VTUG|=nmsG!5Dh0A`WZPi@@2D(Q^%{)QM5mAFEAonXMrME+
zUpUs%q^k^
zMc7nCn=0HBL)dg2S1!(Tm|L{zAk4}*k6XETya=;8jw=%m!n4RdGr$ZiGElN+ozDLY
z{AE@?@)uLsVg{Ijf5w0)cZ05vMY*$esXRJs6}AU#BovpEf`a)ZDNA``=`W5LI
Z=Q+$R(kwJ?(h>P0U
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
diff --git a/src/Api/Api.http b/src/Api/Api.http
new file mode 100644
index 0000000..7a2b9ed
--- /dev/null
+++ b/src/Api/Api.http
@@ -0,0 +1,6 @@
+@Api_HostAddress = http://localhost:5219
+
+GET {{Api_HostAddress}}/weatherforecast/
+Accept: application/json
+
+###
diff --git a/src/Api/Program.cs b/src/Api/Program.cs
new file mode 100644
index 0000000..fda9a7d
--- /dev/null
+++ b/src/Api/Program.cs
@@ -0,0 +1,44 @@
+var builder = WebApplication.CreateBuilder(args);
+
+// Add services to the container.
+// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
+builder.Services.AddEndpointsApiExplorer();
+builder.Services.AddSwaggerGen();
+
+var app = builder.Build();
+
+// Configure the HTTP request pipeline.
+if (app.Environment.IsDevelopment())
+{
+ app.UseSwagger();
+ app.UseSwaggerUI();
+}
+
+app.UseHttpsRedirection();
+
+var summaries = new[]
+{
+ "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
+};
+
+app.MapGet("/weatherforecast", () =>
+{
+ var forecast = Enumerable.Range(1, 5).Select(index =>
+ new WeatherForecast
+ (
+ DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
+ Random.Shared.Next(-20, 55),
+ summaries[Random.Shared.Next(summaries.Length)]
+ ))
+ .ToArray();
+ return forecast;
+})
+.WithName("GetWeatherForecast")
+.WithOpenApi();
+
+app.Run();
+
+record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
+{
+ public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
+}
diff --git a/src/Api/Properties/launchSettings.json b/src/Api/Properties/launchSettings.json
new file mode 100644
index 0000000..fa2d512
--- /dev/null
+++ b/src/Api/Properties/launchSettings.json
@@ -0,0 +1,41 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "iisSettings": {
+ "windowsAuthentication": false,
+ "anonymousAuthentication": true,
+ "iisExpress": {
+ "applicationUrl": "http://localhost:46994",
+ "sslPort": 44360
+ }
+ },
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "launchUrl": "swagger",
+ "applicationUrl": "http://localhost:5219",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "launchUrl": "swagger",
+ "applicationUrl": "https://localhost:7285;http://localhost:5219",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "IIS Express": {
+ "commandName": "IISExpress",
+ "launchBrowser": true,
+ "launchUrl": "swagger",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/src/Api/appsettings.Development.json b/src/Api/appsettings.Development.json
new file mode 100644
index 0000000..ff66ba6
--- /dev/null
+++ b/src/Api/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/src/Api/appsettings.json b/src/Api/appsettings.json
new file mode 100644
index 0000000..4d56694
--- /dev/null
+++ b/src/Api/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/src/Application/Application.csproj b/src/Application/Application.csproj
new file mode 100644
index 0000000..bb23fb7
--- /dev/null
+++ b/src/Application/Application.csproj
@@ -0,0 +1,9 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
diff --git a/src/Application/Class1.cs b/src/Application/Class1.cs
new file mode 100644
index 0000000..f1d69f9
--- /dev/null
+++ b/src/Application/Class1.cs
@@ -0,0 +1,6 @@
+namespace Application;
+
+public class Class1
+{
+
+}
diff --git a/src/Domain/Class1.cs b/src/Domain/Class1.cs
new file mode 100644
index 0000000..dfc5368
--- /dev/null
+++ b/src/Domain/Class1.cs
@@ -0,0 +1,6 @@
+namespace Domain;
+
+public class Class1
+{
+
+}
diff --git a/src/Domain/Domain.csproj b/src/Domain/Domain.csproj
new file mode 100644
index 0000000..bb23fb7
--- /dev/null
+++ b/src/Domain/Domain.csproj
@@ -0,0 +1,9 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
diff --git a/src/Infrastructure/Class1.cs b/src/Infrastructure/Class1.cs
new file mode 100644
index 0000000..301b928
--- /dev/null
+++ b/src/Infrastructure/Class1.cs
@@ -0,0 +1,6 @@
+namespace Infrastructure;
+
+public class Class1
+{
+
+}
diff --git a/src/Infrastructure/Infrastructure.csproj b/src/Infrastructure/Infrastructure.csproj
new file mode 100644
index 0000000..bb23fb7
--- /dev/null
+++ b/src/Infrastructure/Infrastructure.csproj
@@ -0,0 +1,9 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
diff --git a/tests/UnitTests/UnitTest1.cs b/tests/UnitTests/UnitTest1.cs
new file mode 100644
index 0000000..791524d
--- /dev/null
+++ b/tests/UnitTests/UnitTest1.cs
@@ -0,0 +1,10 @@
+namespace UnitTests;
+
+public class UnitTest1
+{
+ [Fact]
+ public void Test1()
+ {
+
+ }
+}
\ No newline at end of file
diff --git a/tests/UnitTests/UnitTests.csproj b/tests/UnitTests/UnitTests.csproj
new file mode 100644
index 0000000..a1d45f6
--- /dev/null
+++ b/tests/UnitTests/UnitTests.csproj
@@ -0,0 +1,23 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
From 59b5e812123442c139df3424037ccd64b7da3f11 Mon Sep 17 00:00:00 2001
From: cbjpdev <42979311+cstacklab@users.noreply.github.com>
Date: Sat, 25 Apr 2026 23:39:14 +0800
Subject: [PATCH 03/33] implement basic structure for CleanApiStarter API with
PostgreSQL and OpenTelemetry
---
.editorconfig | 11 +-
.gitignore | 4 +
.vscode/launch.json | 7 -
.vscode/settings.json | 3 -
AGENTS.md | 111 ++++++++++++++
CleanApi.sln | 67 ---------
CleanApiStarter.slnx | 28 ++++
Directory.Build.props | 2 +-
Directory.Packages.props | 55 +++++--
README.md | 77 +++++++++-
.../migrations/V001__create_words_table.sql | 11 ++
docker-compose.yml | 18 +++
global.json | 10 +-
src/Api/Api.csproj | 14 --
src/Api/Api.http | 6 -
src/Api/Program.cs | 44 ------
src/Api/Properties/launchSettings.json | 41 ------
src/Api/appsettings.Development.json | 8 -
src/Application/Application.csproj | 9 --
src/Application/Class1.cs | 6 -
src/CleanApiStarter.Api/Api.http | 18 +++
.../CleanApiStarter.Api.csproj | 19 +++
.../Endpoints/WordEndpoints.cs | 77 ++++++++++
src/CleanApiStarter.Api/GlobalUsings.cs | 13 ++
src/CleanApiStarter.Api/Program.cs | 25 ++++
.../Properties/launchSettings.json | 15 ++
.../appsettings.Development.json | 11 ++
.../appsettings.json | 0
.../CleanApiStarter.AppHost.csproj | 19 +++
src/CleanApiStarter.AppHost/Program.cs | 19 +++
.../Properties/launchSettings.json | 17 +++
.../CleanApiStarter.Application.csproj | 16 ++
.../DependencyInjection.cs | 10 ++
.../GlobalUsings.cs | 7 +
.../Interfaces/IWordRepository.cs | 10 ++
.../Interfaces/IWordService.cs | 10 ++
.../Models/WordDto.cs | 18 +++
.../Services/WordService.cs | 105 +++++++++++++
.../CleanApiStarter.AspNetCore.csproj | 21 +++
src/CleanApiStarter.AspNetCore/Extensions.cs | 97 ++++++++++++
.../GlobalUsings.cs | 14 ++
.../CleanApiStarter.Domain.csproj | 6 +
src/CleanApiStarter.Domain/Entities/Word.cs | 12 ++
.../CleanApiStarter.Infrastructure.csproj | 17 +++
.../Database/DatabaseConnectionFactory.cs | 14 ++
.../DependencyInjection.cs | 24 +++
.../GlobalUsings.cs | 14 ++
.../Repositories/WordRepository.cs | 121 +++++++++++++++
src/Domain/Class1.cs | 6 -
src/Domain/Domain.csproj | 9 --
src/Infrastructure/Class1.cs | 6 -
src/Infrastructure/Infrastructure.csproj | 9 --
.../CleanApiStarter.UnitTests.csproj | 18 +++
.../Controllers/WordsControllerTests.cs | 131 +++++++++++++++++
.../Repositories/WordRepositoryTests.cs | 61 ++++++++
.../Services/WordServiceTests.cs | 139 ++++++++++++++++++
tests/UnitTests/UnitTest1.cs | 10 --
tests/UnitTests/UnitTests.csproj | 23 ---
58 files changed, 1398 insertions(+), 295 deletions(-)
delete mode 100644 .vscode/launch.json
delete mode 100644 .vscode/settings.json
create mode 100644 AGENTS.md
delete mode 100644 CleanApi.sln
create mode 100644 CleanApiStarter.slnx
create mode 100644 database/migrations/V001__create_words_table.sql
create mode 100644 docker-compose.yml
delete mode 100644 src/Api/Api.csproj
delete mode 100644 src/Api/Api.http
delete mode 100644 src/Api/Program.cs
delete mode 100644 src/Api/Properties/launchSettings.json
delete mode 100644 src/Api/appsettings.Development.json
delete mode 100644 src/Application/Application.csproj
delete mode 100644 src/Application/Class1.cs
create mode 100644 src/CleanApiStarter.Api/Api.http
create mode 100644 src/CleanApiStarter.Api/CleanApiStarter.Api.csproj
create mode 100644 src/CleanApiStarter.Api/Endpoints/WordEndpoints.cs
create mode 100644 src/CleanApiStarter.Api/GlobalUsings.cs
create mode 100644 src/CleanApiStarter.Api/Program.cs
create mode 100644 src/CleanApiStarter.Api/Properties/launchSettings.json
create mode 100644 src/CleanApiStarter.Api/appsettings.Development.json
rename src/{Api => CleanApiStarter.Api}/appsettings.json (100%)
create mode 100644 src/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csproj
create mode 100644 src/CleanApiStarter.AppHost/Program.cs
create mode 100644 src/CleanApiStarter.AppHost/Properties/launchSettings.json
create mode 100644 src/CleanApiStarter.Application/CleanApiStarter.Application.csproj
create mode 100644 src/CleanApiStarter.Application/DependencyInjection.cs
create mode 100644 src/CleanApiStarter.Application/GlobalUsings.cs
create mode 100644 src/CleanApiStarter.Application/Interfaces/IWordRepository.cs
create mode 100644 src/CleanApiStarter.Application/Interfaces/IWordService.cs
create mode 100644 src/CleanApiStarter.Application/Models/WordDto.cs
create mode 100644 src/CleanApiStarter.Application/Services/WordService.cs
create mode 100644 src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj
create mode 100644 src/CleanApiStarter.AspNetCore/Extensions.cs
create mode 100644 src/CleanApiStarter.AspNetCore/GlobalUsings.cs
create mode 100644 src/CleanApiStarter.Domain/CleanApiStarter.Domain.csproj
create mode 100644 src/CleanApiStarter.Domain/Entities/Word.cs
create mode 100644 src/CleanApiStarter.Infrastructure/CleanApiStarter.Infrastructure.csproj
create mode 100644 src/CleanApiStarter.Infrastructure/Database/DatabaseConnectionFactory.cs
create mode 100644 src/CleanApiStarter.Infrastructure/DependencyInjection.cs
create mode 100644 src/CleanApiStarter.Infrastructure/GlobalUsings.cs
create mode 100644 src/CleanApiStarter.Infrastructure/Repositories/WordRepository.cs
delete mode 100644 src/Domain/Class1.cs
delete mode 100644 src/Domain/Domain.csproj
delete mode 100644 src/Infrastructure/Class1.cs
delete mode 100644 src/Infrastructure/Infrastructure.csproj
create mode 100644 tests/CleanApiStarter.UnitTests/CleanApiStarter.UnitTests.csproj
create mode 100644 tests/CleanApiStarter.UnitTests/Controllers/WordsControllerTests.cs
create mode 100644 tests/CleanApiStarter.UnitTests/Repositories/WordRepositoryTests.cs
create mode 100644 tests/CleanApiStarter.UnitTests/Services/WordServiceTests.cs
delete mode 100644 tests/UnitTests/UnitTest1.cs
delete mode 100644 tests/UnitTests/UnitTests.csproj
diff --git a/.editorconfig b/.editorconfig
index 4a9952c..d552917 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -77,10 +77,13 @@ dotnet_remove_unnecessary_suppression_exclusions = none
#### C# Coding Conventions ####
[*.cs]
-# var preferences
-csharp_style_var_elsewhere = false:silent
-csharp_style_var_for_built_in_types = false:silent
-csharp_style_var_when_type_is_apparent = false:silent
+# var preferences
+csharp_style_var_elsewhere = false:warning
+csharp_style_var_for_built_in_types = false:warning
+csharp_style_var_when_type_is_apparent = false:warning
+
+# Prefer explicit types instead of var
+dotnet_diagnostic.IDE0008.severity = warning
# Expression-bodied members
csharp_style_expression_bodied_accessors = true:silent
diff --git a/.gitignore b/.gitignore
index 8a30d25..a3c8cb2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -132,6 +132,10 @@ _ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
+# JetBrains IDE and Junie local assistant files
+.idea/
+.junie/
+
# TeamCity is a build add-in
_TeamCity*
diff --git a/.vscode/launch.json b/.vscode/launch.json
deleted file mode 100644
index 5c7247b..0000000
--- a/.vscode/launch.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- // Use IntelliSense to learn about possible attributes.
- // Hover to view descriptions of existing attributes.
- // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
- "version": "0.2.0",
- "configurations": []
-}
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
deleted file mode 100644
index ebda1eb..0000000
--- a/.vscode/settings.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "dotnet.defaultSolution": "CleanApi.sln"
-}
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..8573ebd
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,111 @@
+# Agent Instructions
+
+This repository is a Clean Architecture API starter template named `CleanApiStarter`.
+
+## Naming
+
+- Use `CleanApiStarter` for solution, project, assembly, and namespace naming.
+- Do not introduce `CleanArchitecture` namespaces or assembly names.
+- Keep project names fully qualified:
+ - `CleanApiStarter.Api`
+ - `CleanApiStarter.Application`
+ - `CleanApiStarter.Domain`
+ - `CleanApiStarter.Infrastructure`
+ - `CleanApiStarter.AppHost`
+ - `CleanApiStarter.AspNetCore`
+ - `CleanApiStarter.UnitTests`
+
+## Solution Structure
+
+- Keep clean architecture application layers under the `/src/` solution folder:
+ - API
+ - Application
+ - Domain
+ - Infrastructure
+- Keep Aspire/runtime support projects under the `/src/Common/` solution folder in `CleanApiStarter.slnx`:
+ - AppHost
+ - AspNetCore
+- Keep database scripts under top-level `database/migrations`.
+- Keep important root files in solution items, including `README.md`, `docker-compose.yml`, `Directory.Build.props`, `Directory.Packages.props`, `.editorconfig`, `.gitignore`, and `global.json`.
+
+## Clean Architecture Rules
+
+- Dependencies point inward:
+ - `Domain` references nothing.
+ - `Application` references `Domain`.
+ - `Infrastructure` references `Application`.
+ - `Api` composes `Application`, `Infrastructure`, and `AspNetCore`.
+- Keep repository interfaces in `Application`, not `Domain`.
+- Keep database implementation details in `Infrastructure`.
+- Do not move `IDatabaseConnectionFactory` into `Application` or `Domain`.
+- Keep domain models persistence-agnostic. For example, `Word.Synonyms` stays `List`, even though Postgres stores it as `jsonb`.
+- Private database projection types such as `WordRow` may live inside their repository when they are only used there.
+
+## API and Application Conventions
+
+- Use Scalar, not Swagger/Swashbuckle.
+- Use `Microsoft.AspNetCore.OpenApi` with:
+ - `builder.Services.AddOpenApi();`
+ - `app.MapOpenApi();`
+ - `app.MapScalarApiReference();`
+- Do not add `Swashbuckle.AspNetCore`.
+- Cancellation tokens are explicit:
+ - Do not use `CancellationToken cancellationToken = default` in service or repository contracts.
+ - API actions should accept `CancellationToken cancellationToken` and pass it through.
+- Use explicit local types. The `.editorconfig` prefers explicit types over `var`.
+- Use project-level `GlobalUsings.cs`; avoid adding file-level `using` directives unless there is a very specific reason.
+
+## Database and Aspire
+
+- Use a single Postgres database: `postgres`.
+- Do not create or reference a separate `wordlibrary` database.
+- AppHost should expose the API connection as `ConnectionStrings__postgres`.
+- `docker-compose.yml` should use `POSTGRES_DB=postgres`.
+- Database schema scripts live in `database/migrations`, for example:
+ - `database/migrations/V001__create_words_table.sql`
+- Do not add API startup database initialization such as `DbInitializer` or DbUp calls. Aspire/Docker init scripts own local schema creation.
+- Docker Postgres init scripts run only on first volume creation. If scripts need to replay, delete the old volume.
+- This repo uses `postgres:latest`. Because Postgres 18+ expects the data volume mounted at `/var/lib/postgresql`, do not mount the volume at `/var/lib/postgresql/data`.
+- In Aspire, use a server resource name that does not conflict with the database resource name, for example:
+ - server resource: `postgres-server`
+ - database resource: `postgres`
+- Aspire Postgres should use a volume mounted at `/var/lib/postgresql`.
+
+## ASP.NET Core Defaults
+
+- Keep `CleanApiStarter.AspNetCore`.
+- It centralizes Aspire-friendly runtime defaults:
+ - OpenTelemetry traces, metrics, logs, and OTLP export
+ - health endpoints
+ - service discovery
+ - default HTTP client resilience
+- API `Program.cs` should stay small and call:
+ - `builder.AddAspNetCoreDefaults();`
+ - `app.UseAspNetCoreDefaults();`
+ - `app.MapDefaultEndpoints();`
+- Shared middleware such as HTTP request logging belongs in `CleanApiStarter.AspNetCore`, not duplicated inside each API project.
+- Keep OpenTelemetry logs configured to include scopes, formatted messages, and parsed state values so structured message-template properties show up in Aspire.
+- Use structured logging message templates instead of interpolated log strings. Prefer stable property names like `{WordId}` and `{WordCount}`.
+
+## API Style
+
+- Prefer Minimal APIs for this template.
+- Keep `Program.cs` small by placing route groups in endpoint mapping classes such as `Api/Endpoints/WordEndpoints.cs`.
+- Do not reintroduce MVC controllers unless the template intentionally changes direction.
+
+## Packages
+
+- Manage versions centrally in `Directory.Packages.props`.
+- Keep `PackageVersion` items sorted alphabetically by `Include`.
+- Do not add package versions directly in individual `.csproj` files.
+
+## Verification
+
+- After structural or package changes, run:
+
+```bash
+dotnet restore CleanApiStarter.slnx --disable-parallel
+dotnet build CleanApiStarter.slnx --no-restore /nr:false -v:minimal
+```
+
+- Aspire AppHost builds may need to run outside a sandbox because the Aspire SDK touches local runtime/process resources.
diff --git a/CleanApi.sln b/CleanApi.sln
deleted file mode 100644
index e7ee670..0000000
--- a/CleanApi.sln
+++ /dev/null
@@ -1,67 +0,0 @@
-
-Microsoft Visual Studio Solution File, Format Version 12.00
-# Visual Studio Version 17
-VisualStudioVersion = 17.0.31903.59
-MinimumVisualStudioVersion = 10.0.40219.1
-Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{B89C7703-FA6E-4F45-81D3-6591C0E97548}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Api", "src\Api\Api.csproj", "{21EE4D82-D963-4832-B621-B8B5138377B7}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Application", "src\Application\Application.csproj", "{A804F7DE-51BF-44CB-A2FA-680FC68571DB}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Domain", "src\Domain\Domain.csproj", "{3E8AD1A4-B7A2-4FD6-A438-0E709E54C5CA}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure", "src\Infrastructure\Infrastructure.csproj", "{662A350A-2580-4B96-A1E6-C8C742E60F78}"
-EndProject
-Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{09BA79D0-7A13-419E-A21E-C32B3E7EAE0C}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnitTests", "tests\UnitTests\UnitTests.csproj", "{3BE68B1A-FF8A-49C5-A787-6B26600D12C8}"
-EndProject
-Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{F1E8E655-4E70-45DD-9EBA-7CFF831E84A9}"
- ProjectSection(SolutionItems) = preProject
- Directory.Build.props = Directory.Build.props
- .editorconfig = .editorconfig
- global.json = global.json
- .gitignore = .gitignore
- Directory.Packages.props = Directory.Packages.props
- .config\dotnet-tools.json = .config\dotnet-tools.json
- EndProjectSection
-EndProject
-Global
- GlobalSection(SolutionConfigurationPlatforms) = preSolution
- Debug|Any CPU = Debug|Any CPU
- Release|Any CPU = Release|Any CPU
- EndGlobalSection
- GlobalSection(ProjectConfigurationPlatforms) = postSolution
- {21EE4D82-D963-4832-B621-B8B5138377B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {21EE4D82-D963-4832-B621-B8B5138377B7}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {21EE4D82-D963-4832-B621-B8B5138377B7}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {21EE4D82-D963-4832-B621-B8B5138377B7}.Release|Any CPU.Build.0 = Release|Any CPU
- {A804F7DE-51BF-44CB-A2FA-680FC68571DB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {A804F7DE-51BF-44CB-A2FA-680FC68571DB}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {A804F7DE-51BF-44CB-A2FA-680FC68571DB}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {A804F7DE-51BF-44CB-A2FA-680FC68571DB}.Release|Any CPU.Build.0 = Release|Any CPU
- {3E8AD1A4-B7A2-4FD6-A438-0E709E54C5CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {3E8AD1A4-B7A2-4FD6-A438-0E709E54C5CA}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {3E8AD1A4-B7A2-4FD6-A438-0E709E54C5CA}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {3E8AD1A4-B7A2-4FD6-A438-0E709E54C5CA}.Release|Any CPU.Build.0 = Release|Any CPU
- {662A350A-2580-4B96-A1E6-C8C742E60F78}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {662A350A-2580-4B96-A1E6-C8C742E60F78}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {662A350A-2580-4B96-A1E6-C8C742E60F78}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {662A350A-2580-4B96-A1E6-C8C742E60F78}.Release|Any CPU.Build.0 = Release|Any CPU
- {3BE68B1A-FF8A-49C5-A787-6B26600D12C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {3BE68B1A-FF8A-49C5-A787-6B26600D12C8}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {3BE68B1A-FF8A-49C5-A787-6B26600D12C8}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {3BE68B1A-FF8A-49C5-A787-6B26600D12C8}.Release|Any CPU.Build.0 = Release|Any CPU
- EndGlobalSection
- GlobalSection(SolutionProperties) = preSolution
- HideSolutionNode = FALSE
- EndGlobalSection
- GlobalSection(NestedProjects) = preSolution
- {21EE4D82-D963-4832-B621-B8B5138377B7} = {B89C7703-FA6E-4F45-81D3-6591C0E97548}
- {A804F7DE-51BF-44CB-A2FA-680FC68571DB} = {B89C7703-FA6E-4F45-81D3-6591C0E97548}
- {3E8AD1A4-B7A2-4FD6-A438-0E709E54C5CA} = {B89C7703-FA6E-4F45-81D3-6591C0E97548}
- {662A350A-2580-4B96-A1E6-C8C742E60F78} = {B89C7703-FA6E-4F45-81D3-6591C0E97548}
- {3BE68B1A-FF8A-49C5-A787-6B26600D12C8} = {09BA79D0-7A13-419E-A21E-C32B3E7EAE0C}
- EndGlobalSection
-EndGlobal
diff --git a/CleanApiStarter.slnx b/CleanApiStarter.slnx
new file mode 100644
index 0000000..9b35a61
--- /dev/null
+++ b/CleanApiStarter.slnx
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Directory.Build.props b/Directory.Build.props
index 2b78872..f2acb86 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -1,7 +1,7 @@
- net8.0
+ net10.0
true
enable
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 3121cd2..2437245 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -1,16 +1,41 @@
-
-
-
- true
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+ true
+ 13.2.4
+ 10.0.0
+ 10.0.0
+ 10.0.0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/README.md b/README.md
index a12e93f..4c890cc 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,75 @@
-# CleanApi
-Dotnet API based on Clean Architecture
+# CleanApiStarter
+
+A Clean Architecture API starter template for .NET, Aspire, PostgreSQL, and OpenTelemetry.
+
+The API uses Minimal APIs with endpoint mapping classes so `Program.cs` stays small while routes remain easy to find.
+
+## Learn OpenTelemetry with .NET Aspire
+
+This solution can run the existing API with a local PostgreSQL container through .NET Aspire. Aspire also provides the local dashboard where you can inspect logs, traces, metrics, and resource health.
+
+### Run with Aspire
+
+```bash
+dotnet run --project src/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csproj
+```
+
+Open the Aspire dashboard URL printed in the terminal. The AppHost starts:
+
+- `api`: the ASP.NET Core API
+- `postgres`: a local PostgreSQL container
+- `postgres`: the API database
+- `pgAdmin`: a browser UI for inspecting PostgreSQL
+
+The API receives its connection string from Aspire as `ConnectionStrings__postgres`. Running the API directly still uses `ConnectionStrings:DefaultConnection` from `src/CleanApiStarter.Api/appsettings.Development.json`, which matches the existing `docker-compose.yml`.
+
+Database scripts live in `database`. Aspire and Docker Compose copy `database/migrations` into the Postgres container init folder. Docker runs them only when the Postgres data directory is created for the first time. Because the AppHost uses the persistent `clean-api-starter-postgres-data` volume, delete that Docker volume if you need to replay the scripts from scratch.
+
+- `database/migrations`: versioned schema scripts that can also be reused by CI/CD migration tools such as Flyway.
+
+### What to Observe
+
+After the dashboard is running, send requests to the API through Scalar or another HTTP client. In the Aspire dashboard:
+
+- Logs show structured application and framework log entries. The word use cases emit fields such as `WordId`, `SynonymCount`, `WordCount`, and `UpdateSucceeded`.
+- Request logs show method, path, response status code, and duration without logging request or response bodies. `CleanApiStarter.AspNetCore` keeps the `Microsoft.AspNetCore.HttpLogging` category at `Information` even when broader ASP.NET Core logs are filtered to `Warning`.
+- Traces show incoming HTTP requests and PostgreSQL commands from Npgsql.
+- Metrics show ASP.NET Core, HTTP client, and .NET runtime measurements.
+- Health checks expose `/health` and `/alive` in Development.
+
+### Coding Conventions
+
+Cancellation tokens are explicit at application and infrastructure boundaries. Do not use `CancellationToken cancellationToken = default` in service or repository contracts. API actions should accept a `CancellationToken` parameter and pass it through the application and repository calls.
+
+Use structured logging message templates instead of interpolated log strings. Prefer stable property names such as `{WordId}` or `{WordCount}` so Aspire and OpenTelemetry can index and filter them.
+
+### Run with Docker Compose Only
+
+If you want to run PostgreSQL without Aspire:
+
+```bash
+docker compose up -d
+dotnet run --project src/CleanApiStarter.Api/CleanApiStarter.Api.csproj
+```
+
+### Troubleshooting Aspire Certificates
+
+If the Aspire dashboard logs an `UntrustedRoot` error while calling gRPC services, or the AppHost prints `No trusted Aspire development certificate was found`, trust the local ASP.NET Core development certificate:
+
+```bash
+dotnet dev-certs https --check --trust
+```
+
+If the certificate is missing or untrusted, reset it and trust it again:
+
+```bash
+dotnet dev-certs https --clean
+dotnet dev-certs https --trust
+dotnet dev-certs https --check --trust
+```
+
+On macOS, accept the Keychain prompt and enter your password if requested. Then close and reopen the browser, and start Aspire again:
+
+```bash
+dotnet run --project src/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csproj
+```
diff --git a/database/migrations/V001__create_words_table.sql b/database/migrations/V001__create_words_table.sql
new file mode 100644
index 0000000..48ffe64
--- /dev/null
+++ b/database/migrations/V001__create_words_table.sql
@@ -0,0 +1,11 @@
+CREATE TABLE IF NOT EXISTS words (
+ id UUID PRIMARY KEY,
+ text VARCHAR(100) NOT NULL,
+ meaning TEXT NOT NULL,
+ synonyms JSONB NOT NULL,
+ usage_example TEXT NOT NULL,
+ created_at TIMESTAMP NOT NULL,
+ updated_at TIMESTAMP NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_words_text ON words(text);
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..1536d3e
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,18 @@
+version: '3.8'
+
+services:
+ postgres:
+ image: postgres:latest
+ container_name: clean-api-starter-postgres
+ environment:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: postgres
+ ports:
+ - "5432:5432"
+ volumes:
+ - postgres-data:/var/lib/postgresql
+ - ./database/migrations:/docker-entrypoint-initdb.d:ro
+
+volumes:
+ postgres-data:
diff --git a/global.json b/global.json
index 5c4e745..8994a62 100644
--- a/global.json
+++ b/global.json
@@ -1,6 +1,6 @@
-{
- "sdk": {
- "version": "8.0.100",
- "rollForward": "latestFeature"
- }
+{
+ "sdk": {
+ "version": "10.0.203",
+ "rollForward": "latestFeature"
+ }
}
\ No newline at end of file
diff --git a/src/Api/Api.csproj b/src/Api/Api.csproj
deleted file mode 100644
index a2d2523..0000000
--- a/src/Api/Api.csproj
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
- net8.0
- enable
- enable
-
-
-
-
-
-
-
-
diff --git a/src/Api/Api.http b/src/Api/Api.http
deleted file mode 100644
index 7a2b9ed..0000000
--- a/src/Api/Api.http
+++ /dev/null
@@ -1,6 +0,0 @@
-@Api_HostAddress = http://localhost:5219
-
-GET {{Api_HostAddress}}/weatherforecast/
-Accept: application/json
-
-###
diff --git a/src/Api/Program.cs b/src/Api/Program.cs
deleted file mode 100644
index fda9a7d..0000000
--- a/src/Api/Program.cs
+++ /dev/null
@@ -1,44 +0,0 @@
-var builder = WebApplication.CreateBuilder(args);
-
-// Add services to the container.
-// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
-builder.Services.AddEndpointsApiExplorer();
-builder.Services.AddSwaggerGen();
-
-var app = builder.Build();
-
-// Configure the HTTP request pipeline.
-if (app.Environment.IsDevelopment())
-{
- app.UseSwagger();
- app.UseSwaggerUI();
-}
-
-app.UseHttpsRedirection();
-
-var summaries = new[]
-{
- "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
-};
-
-app.MapGet("/weatherforecast", () =>
-{
- var forecast = Enumerable.Range(1, 5).Select(index =>
- new WeatherForecast
- (
- DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
- Random.Shared.Next(-20, 55),
- summaries[Random.Shared.Next(summaries.Length)]
- ))
- .ToArray();
- return forecast;
-})
-.WithName("GetWeatherForecast")
-.WithOpenApi();
-
-app.Run();
-
-record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
-{
- public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
-}
diff --git a/src/Api/Properties/launchSettings.json b/src/Api/Properties/launchSettings.json
deleted file mode 100644
index fa2d512..0000000
--- a/src/Api/Properties/launchSettings.json
+++ /dev/null
@@ -1,41 +0,0 @@
-{
- "$schema": "http://json.schemastore.org/launchsettings.json",
- "iisSettings": {
- "windowsAuthentication": false,
- "anonymousAuthentication": true,
- "iisExpress": {
- "applicationUrl": "http://localhost:46994",
- "sslPort": 44360
- }
- },
- "profiles": {
- "http": {
- "commandName": "Project",
- "dotnetRunMessages": true,
- "launchBrowser": true,
- "launchUrl": "swagger",
- "applicationUrl": "http://localhost:5219",
- "environmentVariables": {
- "ASPNETCORE_ENVIRONMENT": "Development"
- }
- },
- "https": {
- "commandName": "Project",
- "dotnetRunMessages": true,
- "launchBrowser": true,
- "launchUrl": "swagger",
- "applicationUrl": "https://localhost:7285;http://localhost:5219",
- "environmentVariables": {
- "ASPNETCORE_ENVIRONMENT": "Development"
- }
- },
- "IIS Express": {
- "commandName": "IISExpress",
- "launchBrowser": true,
- "launchUrl": "swagger",
- "environmentVariables": {
- "ASPNETCORE_ENVIRONMENT": "Development"
- }
- }
- }
-}
diff --git a/src/Api/appsettings.Development.json b/src/Api/appsettings.Development.json
deleted file mode 100644
index ff66ba6..0000000
--- a/src/Api/appsettings.Development.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "Logging": {
- "LogLevel": {
- "Default": "Information",
- "Microsoft.AspNetCore": "Warning"
- }
- }
-}
diff --git a/src/Application/Application.csproj b/src/Application/Application.csproj
deleted file mode 100644
index bb23fb7..0000000
--- a/src/Application/Application.csproj
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
- net8.0
- enable
- enable
-
-
-
diff --git a/src/Application/Class1.cs b/src/Application/Class1.cs
deleted file mode 100644
index f1d69f9..0000000
--- a/src/Application/Class1.cs
+++ /dev/null
@@ -1,6 +0,0 @@
-namespace Application;
-
-public class Class1
-{
-
-}
diff --git a/src/CleanApiStarter.Api/Api.http b/src/CleanApiStarter.Api/Api.http
new file mode 100644
index 0000000..f420003
--- /dev/null
+++ b/src/CleanApiStarter.Api/Api.http
@@ -0,0 +1,18 @@
+@CleanApiStarter_Api_HostAddress = http://localhost:5219
+
+GET {{CleanApiStarter_Api_HostAddress}}/api/words
+Accept: application/json
+
+###
+
+POST {{CleanApiStarter_Api_HostAddress}}/api/words
+Content-Type: application/json
+
+{
+ "text": "observable",
+ "meaning": "Able to be noticed, measured, or monitored.",
+ "synonyms": ["visible", "measurable"],
+ "usageExample": "OpenTelemetry makes API behavior observable."
+}
+
+###
diff --git a/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj b/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj
new file mode 100644
index 0000000..6014433
--- /dev/null
+++ b/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj
@@ -0,0 +1,19 @@
+
+
+
+ CleanApiStarter.Api
+ CleanApiStarter.Api
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/CleanApiStarter.Api/Endpoints/WordEndpoints.cs b/src/CleanApiStarter.Api/Endpoints/WordEndpoints.cs
new file mode 100644
index 0000000..e5fdfb7
--- /dev/null
+++ b/src/CleanApiStarter.Api/Endpoints/WordEndpoints.cs
@@ -0,0 +1,77 @@
+namespace CleanApiStarter.Api.Endpoints;
+
+public static class WordEndpoints
+{
+ public static IEndpointRouteBuilder MapWordEndpoints(this IEndpointRouteBuilder endpoints)
+ {
+ RouteGroupBuilder group = endpoints.MapGroup("/api/words")
+ .WithTags("Words");
+
+ group.MapPost("/", CreateWord)
+ .WithName(nameof(CreateWord));
+
+ group.MapGet("/{id:guid}", GetWord)
+ .WithName(nameof(GetWord));
+
+ group.MapGet("/", GetAllWords)
+ .WithName(nameof(GetAllWords));
+
+ group.MapPut("/{id:guid}", UpdateWord)
+ .WithName(nameof(UpdateWord));
+
+ group.MapDelete("/{id:guid}", DeleteWord)
+ .WithName(nameof(DeleteWord));
+
+ return endpoints;
+ }
+
+ private static async Task CreateWord(
+ CreateWordDto wordDto,
+ IWordService wordService,
+ CancellationToken cancellationToken)
+ {
+ Guid id = await wordService.AddWordAsync(wordDto, cancellationToken);
+
+ return Results.Created($"/api/words/{id}", id);
+ }
+
+ private static async Task GetWord(
+ Guid id,
+ IWordService wordService,
+ CancellationToken cancellationToken)
+ {
+ WordDto? word = await wordService.GetWordByIdAsync(id, cancellationToken);
+
+ return word == null ? Results.NotFound() : Results.Ok(word);
+ }
+
+ private static async Task GetAllWords(
+ IWordService wordService,
+ CancellationToken cancellationToken)
+ {
+ IEnumerable words = await wordService.GetAllWordsAsync(cancellationToken);
+
+ return Results.Ok(words);
+ }
+
+ private static async Task UpdateWord(
+ Guid id,
+ CreateWordDto wordDto,
+ IWordService wordService,
+ CancellationToken cancellationToken)
+ {
+ bool success = await wordService.UpdateWordAsync(id, wordDto, cancellationToken);
+
+ return success ? Results.NoContent() : Results.NotFound();
+ }
+
+ private static async Task DeleteWord(
+ Guid id,
+ IWordService wordService,
+ CancellationToken cancellationToken)
+ {
+ bool success = await wordService.DeleteWordAsync(id, cancellationToken);
+
+ return success ? Results.NoContent() : Results.NotFound();
+ }
+}
diff --git a/src/CleanApiStarter.Api/GlobalUsings.cs b/src/CleanApiStarter.Api/GlobalUsings.cs
new file mode 100644
index 0000000..30dfefd
--- /dev/null
+++ b/src/CleanApiStarter.Api/GlobalUsings.cs
@@ -0,0 +1,13 @@
+// Global using directives
+
+global using CleanApiStarter.Api.Endpoints;
+global using CleanApiStarter.Application;
+global using CleanApiStarter.Application.Interfaces;
+global using CleanApiStarter.Application.Models;
+global using CleanApiStarter.AspNetCore;
+global using CleanApiStarter.Infrastructure;
+
+global using Microsoft.AspNetCore.Http;
+global using Microsoft.AspNetCore.Routing;
+
+global using Scalar.AspNetCore;
diff --git a/src/CleanApiStarter.Api/Program.cs b/src/CleanApiStarter.Api/Program.cs
new file mode 100644
index 0000000..1f4ec8e
--- /dev/null
+++ b/src/CleanApiStarter.Api/Program.cs
@@ -0,0 +1,25 @@
+WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
+
+builder.AddAspNetCoreDefaults();
+
+builder.Services.AddEndpointsApiExplorer();
+builder.Services.AddOpenApi();
+
+builder.Services.AddApplication();
+builder.Services.AddInfrastructure(builder.Configuration);
+
+WebApplication app = builder.Build();
+
+// Configure the HTTP request pipeline.
+if (app.Environment.IsDevelopment())
+{
+ app.MapOpenApi();
+ app.MapScalarApiReference();
+}
+
+app.UseAspNetCoreDefaults();
+app.UseHttpsRedirection();
+app.MapWordEndpoints();
+app.MapDefaultEndpoints();
+
+app.Run();
diff --git a/src/CleanApiStarter.Api/Properties/launchSettings.json b/src/CleanApiStarter.Api/Properties/launchSettings.json
new file mode 100644
index 0000000..a6d24cd
--- /dev/null
+++ b/src/CleanApiStarter.Api/Properties/launchSettings.json
@@ -0,0 +1,15 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "launchUrl": "scalar",
+ "applicationUrl": "https://localhost:7285",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/src/CleanApiStarter.Api/appsettings.Development.json b/src/CleanApiStarter.Api/appsettings.Development.json
new file mode 100644
index 0000000..7de2b6c
--- /dev/null
+++ b/src/CleanApiStarter.Api/appsettings.Development.json
@@ -0,0 +1,11 @@
+{
+ "ConnectionStrings": {
+ "DefaultConnection": "Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=postgres"
+ },
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/src/Api/appsettings.json b/src/CleanApiStarter.Api/appsettings.json
similarity index 100%
rename from src/Api/appsettings.json
rename to src/CleanApiStarter.Api/appsettings.json
diff --git a/src/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csproj b/src/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csproj
new file mode 100644
index 0000000..d40f932
--- /dev/null
+++ b/src/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csproj
@@ -0,0 +1,19 @@
+
+
+
+ Exe
+ CleanApiStarter.AppHost
+ CleanApiStarter.AppHost
+ clean-api-starter-apphost
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/CleanApiStarter.AppHost/Program.cs b/src/CleanApiStarter.AppHost/Program.cs
new file mode 100644
index 0000000..823eb18
--- /dev/null
+++ b/src/CleanApiStarter.AppHost/Program.cs
@@ -0,0 +1,19 @@
+IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(args);
+
+string databasePath = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, "..", "..", "database"));
+string databaseMigrationsPath = Path.Combine(databasePath, "migrations");
+
+IResourceBuilder postgresServer = builder.AddPostgres("postgres-server")
+ .WithImageTag("latest")
+ .WithVolume("clean-api-starter-postgres-data", "/var/lib/postgresql")
+ .WithInitFiles(databaseMigrationsPath)
+ .WithPgAdmin();
+
+IResourceBuilder database = postgresServer.AddDatabase("postgres")
+ .WithCreationScript("SELECT 1;");
+
+builder.AddProject("api")
+ .WithReference(database)
+ .WaitFor(database);
+
+builder.Build().Run();
diff --git a/src/CleanApiStarter.AppHost/Properties/launchSettings.json b/src/CleanApiStarter.AppHost/Properties/launchSettings.json
new file mode 100644
index 0000000..046e309
--- /dev/null
+++ b/src/CleanApiStarter.AppHost/Properties/launchSettings.json
@@ -0,0 +1,17 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:17134;http://localhost:15170",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21030",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22057"
+ }
+ }
+ }
+}
diff --git a/src/CleanApiStarter.Application/CleanApiStarter.Application.csproj b/src/CleanApiStarter.Application/CleanApiStarter.Application.csproj
new file mode 100644
index 0000000..8b8bb4d
--- /dev/null
+++ b/src/CleanApiStarter.Application/CleanApiStarter.Application.csproj
@@ -0,0 +1,16 @@
+
+
+
+ CleanApiStarter.Application
+ CleanApiStarter.Application
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/CleanApiStarter.Application/DependencyInjection.cs b/src/CleanApiStarter.Application/DependencyInjection.cs
new file mode 100644
index 0000000..53567a8
--- /dev/null
+++ b/src/CleanApiStarter.Application/DependencyInjection.cs
@@ -0,0 +1,10 @@
+namespace CleanApiStarter.Application;
+
+public static class DependencyInjection
+{
+ public static IServiceCollection AddApplication(this IServiceCollection services)
+ {
+ services.AddScoped();
+ return services;
+ }
+}
\ No newline at end of file
diff --git a/src/CleanApiStarter.Application/GlobalUsings.cs b/src/CleanApiStarter.Application/GlobalUsings.cs
new file mode 100644
index 0000000..f169a6b
--- /dev/null
+++ b/src/CleanApiStarter.Application/GlobalUsings.cs
@@ -0,0 +1,7 @@
+global using CleanApiStarter.Application.Interfaces;
+global using CleanApiStarter.Application.Models;
+global using CleanApiStarter.Application.Services;
+global using CleanApiStarter.Domain.Entities;
+
+global using Microsoft.Extensions.DependencyInjection;
+global using Microsoft.Extensions.Logging;
diff --git a/src/CleanApiStarter.Application/Interfaces/IWordRepository.cs b/src/CleanApiStarter.Application/Interfaces/IWordRepository.cs
new file mode 100644
index 0000000..c38a9c3
--- /dev/null
+++ b/src/CleanApiStarter.Application/Interfaces/IWordRepository.cs
@@ -0,0 +1,10 @@
+namespace CleanApiStarter.Application.Interfaces;
+
+public interface IWordRepository
+{
+ Task AddWordAsync(Word word, CancellationToken cancellationToken);
+ Task GetWordByIdAsync(Guid id, CancellationToken cancellationToken);
+ Task> GetAllWordsAsync(CancellationToken cancellationToken);
+ Task UpdateWordAsync(Word word, CancellationToken cancellationToken);
+ Task DeleteWordAsync(Guid id, CancellationToken cancellationToken);
+}
\ No newline at end of file
diff --git a/src/CleanApiStarter.Application/Interfaces/IWordService.cs b/src/CleanApiStarter.Application/Interfaces/IWordService.cs
new file mode 100644
index 0000000..46f0c59
--- /dev/null
+++ b/src/CleanApiStarter.Application/Interfaces/IWordService.cs
@@ -0,0 +1,10 @@
+namespace CleanApiStarter.Application.Interfaces;
+
+public interface IWordService
+{
+ Task AddWordAsync(CreateWordDto wordDto, CancellationToken cancellationToken);
+ Task GetWordByIdAsync(Guid id, CancellationToken cancellationToken);
+ Task> GetAllWordsAsync(CancellationToken cancellationToken);
+ Task UpdateWordAsync(Guid id, CreateWordDto wordDto, CancellationToken cancellationToken);
+ Task DeleteWordAsync(Guid id, CancellationToken cancellationToken);
+}
diff --git a/src/CleanApiStarter.Application/Models/WordDto.cs b/src/CleanApiStarter.Application/Models/WordDto.cs
new file mode 100644
index 0000000..645bf2c
--- /dev/null
+++ b/src/CleanApiStarter.Application/Models/WordDto.cs
@@ -0,0 +1,18 @@
+namespace CleanApiStarter.Application.Models;
+
+public class WordDto
+{
+ public Guid Id { get; set; }
+ public string Text { get; set; } = string.Empty;
+ public string Meaning { get; set; } = string.Empty;
+ public List Synonyms { get; set; } = new();
+ public string UsageExample { get; set; } = string.Empty;
+}
+
+public class CreateWordDto
+{
+ public string Text { get; set; } = string.Empty;
+ public string Meaning { get; set; } = string.Empty;
+ public List Synonyms { get; set; } = new();
+ public string UsageExample { get; set; } = string.Empty;
+}
\ No newline at end of file
diff --git a/src/CleanApiStarter.Application/Services/WordService.cs b/src/CleanApiStarter.Application/Services/WordService.cs
new file mode 100644
index 0000000..ffa6d09
--- /dev/null
+++ b/src/CleanApiStarter.Application/Services/WordService.cs
@@ -0,0 +1,105 @@
+namespace CleanApiStarter.Application.Services;
+
+public class WordService(IWordRepository wordRepository, ILogger logger) : IWordService
+{
+ public async Task AddWordAsync(CreateWordDto wordDto, CancellationToken cancellationToken)
+ {
+ Word word = new()
+ {
+ Id = Guid.NewGuid(),
+ Text = wordDto.Text,
+ Meaning = wordDto.Meaning,
+ Synonyms = wordDto.Synonyms,
+ UsageExample = wordDto.UsageExample,
+ CreatedAt = DateTime.UtcNow
+ };
+
+ logger.LogInformation(
+ "Creating word {WordId} with {SynonymCount} synonyms",
+ word.Id,
+ word.Synonyms.Count);
+
+ Guid wordId = await wordRepository.AddWordAsync(word, cancellationToken);
+
+ logger.LogInformation("Created word {WordId}", wordId);
+
+ return wordId;
+ }
+
+ public async Task GetWordByIdAsync(Guid id, CancellationToken cancellationToken)
+ {
+ Word? word = await wordRepository.GetWordByIdAsync(id, cancellationToken);
+
+ if (word == null)
+ {
+ logger.LogWarning("Word {WordId} was not found", id);
+ return null;
+ }
+
+ logger.LogInformation("Retrieved word {WordId}", id);
+
+ return MapToDto(word);
+ }
+
+ public async Task> GetAllWordsAsync(CancellationToken cancellationToken)
+ {
+ IEnumerable words = await wordRepository.GetAllWordsAsync(cancellationToken);
+ Word[] wordList = words.ToArray();
+
+ logger.LogInformation("Retrieved {WordCount} words", wordList.Length);
+
+ return wordList.Select(MapToDto);
+ }
+
+ public async Task UpdateWordAsync(Guid id, CreateWordDto wordDto, CancellationToken cancellationToken)
+ {
+ Word? existingWord = await wordRepository.GetWordByIdAsync(id, cancellationToken);
+ if (existingWord == null)
+ {
+ logger.LogWarning("Word {WordId} could not be updated because it was not found", id);
+ return false;
+ }
+
+ existingWord.Text = wordDto.Text;
+ existingWord.Meaning = wordDto.Meaning;
+ existingWord.Synonyms = wordDto.Synonyms;
+ existingWord.UsageExample = wordDto.UsageExample;
+ existingWord.UpdatedAt = DateTime.UtcNow;
+
+ bool updated = await wordRepository.UpdateWordAsync(existingWord, cancellationToken);
+
+ logger.LogInformation(
+ "Updated word {WordId}; success: {UpdateSucceeded}",
+ id,
+ updated);
+
+ return updated;
+ }
+
+ public async Task DeleteWordAsync(Guid id, CancellationToken cancellationToken)
+ {
+ bool deleted = await wordRepository.DeleteWordAsync(id, cancellationToken);
+
+ if (!deleted)
+ {
+ logger.LogWarning("Word {WordId} could not be deleted because it was not found", id);
+ return false;
+ }
+
+ logger.LogInformation("Deleted word {WordId}", id);
+
+ return true;
+ }
+
+ private static WordDto MapToDto(Word word)
+ {
+ return new WordDto
+ {
+ Id = word.Id,
+ Text = word.Text,
+ Meaning = word.Meaning,
+ Synonyms = word.Synonyms,
+ UsageExample = word.UsageExample
+ };
+ }
+}
diff --git a/src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj b/src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj
new file mode 100644
index 0000000..73f91c4
--- /dev/null
+++ b/src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj
@@ -0,0 +1,21 @@
+
+
+
+ CleanApiStarter.AspNetCore
+ CleanApiStarter.AspNetCore
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/CleanApiStarter.AspNetCore/Extensions.cs b/src/CleanApiStarter.AspNetCore/Extensions.cs
new file mode 100644
index 0000000..89738df
--- /dev/null
+++ b/src/CleanApiStarter.AspNetCore/Extensions.cs
@@ -0,0 +1,97 @@
+namespace CleanApiStarter.AspNetCore;
+
+public static class Extensions
+{
+ public static IHostApplicationBuilder AddAspNetCoreDefaults(this IHostApplicationBuilder builder)
+ {
+ builder.ConfigureOpenTelemetry();
+ builder.Logging.AddFilter("Microsoft.AspNetCore.HttpLogging", LogLevel.Information);
+
+ builder.Services.AddHttpLogging(options =>
+ {
+ options.LoggingFields =
+ HttpLoggingFields.RequestMethod |
+ HttpLoggingFields.RequestPath |
+ HttpLoggingFields.ResponseStatusCode |
+ HttpLoggingFields.Duration;
+ });
+
+ builder.Services.AddServiceDiscovery();
+
+ builder.Services.ConfigureHttpClientDefaults(http =>
+ {
+ http.AddStandardResilienceHandler();
+ http.AddServiceDiscovery();
+ });
+
+ builder.Services.AddHealthChecks()
+ .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
+
+ return builder;
+ }
+
+ public static WebApplication UseAspNetCoreDefaults(this WebApplication app)
+ {
+ app.UseHttpLogging();
+
+ return app;
+ }
+
+ public static WebApplication MapDefaultEndpoints(this WebApplication app)
+ {
+ if (app.Environment.IsDevelopment())
+ {
+ app.MapHealthChecks("/health");
+ app.MapHealthChecks("/alive", new HealthCheckOptions
+ {
+ Predicate = registration => registration.Tags.Contains("live")
+ });
+ }
+
+ return app;
+ }
+
+ private static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder)
+ {
+ builder.Logging.AddOpenTelemetry(logging =>
+ {
+ logging.IncludeFormattedMessage = true;
+ logging.IncludeScopes = true;
+ logging.ParseStateValues = true;
+ });
+
+ builder.Services.AddOpenTelemetry()
+ .WithMetrics(metrics =>
+ {
+ metrics.AddAspNetCoreInstrumentation()
+ .AddHttpClientInstrumentation()
+ .AddNpgsqlInstrumentation()
+ .AddRuntimeInstrumentation();
+ })
+ .WithTracing(tracing =>
+ {
+ tracing.AddSource(builder.Environment.ApplicationName)
+ .AddAspNetCoreInstrumentation()
+ .AddHttpClientInstrumentation()
+ .AddNpgsql();
+ });
+
+ builder.AddOpenTelemetryExporters();
+
+ return builder;
+ }
+
+ private static IHostApplicationBuilder AddOpenTelemetryExporters(this IHostApplicationBuilder builder)
+ {
+ bool useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
+
+ if (useOtlpExporter)
+ {
+ builder.Services.Configure(logging => logging.AddOtlpExporter());
+ builder.Services.ConfigureOpenTelemetryMeterProvider(metrics => metrics.AddOtlpExporter());
+ builder.Services.ConfigureOpenTelemetryTracerProvider(tracing => tracing.AddOtlpExporter());
+ }
+
+ return builder;
+ }
+}
diff --git a/src/CleanApiStarter.AspNetCore/GlobalUsings.cs b/src/CleanApiStarter.AspNetCore/GlobalUsings.cs
new file mode 100644
index 0000000..c47d19a
--- /dev/null
+++ b/src/CleanApiStarter.AspNetCore/GlobalUsings.cs
@@ -0,0 +1,14 @@
+global using Microsoft.AspNetCore.Builder;
+global using Microsoft.AspNetCore.Diagnostics.HealthChecks;
+global using Microsoft.AspNetCore.HttpLogging;
+global using Microsoft.Extensions.DependencyInjection;
+global using Microsoft.Extensions.Diagnostics.HealthChecks;
+global using Microsoft.Extensions.Hosting;
+global using Microsoft.Extensions.Logging;
+
+global using Npgsql;
+
+global using OpenTelemetry;
+global using OpenTelemetry.Logs;
+global using OpenTelemetry.Metrics;
+global using OpenTelemetry.Trace;
diff --git a/src/CleanApiStarter.Domain/CleanApiStarter.Domain.csproj b/src/CleanApiStarter.Domain/CleanApiStarter.Domain.csproj
new file mode 100644
index 0000000..115c60c
--- /dev/null
+++ b/src/CleanApiStarter.Domain/CleanApiStarter.Domain.csproj
@@ -0,0 +1,6 @@
+
+
+ CleanApiStarter.Domain
+ CleanApiStarter.Domain
+
+
\ No newline at end of file
diff --git a/src/CleanApiStarter.Domain/Entities/Word.cs b/src/CleanApiStarter.Domain/Entities/Word.cs
new file mode 100644
index 0000000..177c398
--- /dev/null
+++ b/src/CleanApiStarter.Domain/Entities/Word.cs
@@ -0,0 +1,12 @@
+namespace CleanApiStarter.Domain.Entities;
+
+public class Word
+{
+ public Guid Id { get; init; }
+ public string Text { get; set; } = string.Empty;
+ public string Meaning { get; set; } = string.Empty;
+ public List Synonyms { get; set; } = [];
+ public string UsageExample { get; set; } = string.Empty;
+ public DateTime CreatedAt { get; set; }
+ public DateTime? UpdatedAt { get; set; }
+}
\ No newline at end of file
diff --git a/src/CleanApiStarter.Infrastructure/CleanApiStarter.Infrastructure.csproj b/src/CleanApiStarter.Infrastructure/CleanApiStarter.Infrastructure.csproj
new file mode 100644
index 0000000..772f2b1
--- /dev/null
+++ b/src/CleanApiStarter.Infrastructure/CleanApiStarter.Infrastructure.csproj
@@ -0,0 +1,17 @@
+
+
+
+ CleanApiStarter.Infrastructure
+ CleanApiStarter.Infrastructure
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/CleanApiStarter.Infrastructure/Database/DatabaseConnectionFactory.cs b/src/CleanApiStarter.Infrastructure/Database/DatabaseConnectionFactory.cs
new file mode 100644
index 0000000..c406b04
--- /dev/null
+++ b/src/CleanApiStarter.Infrastructure/Database/DatabaseConnectionFactory.cs
@@ -0,0 +1,14 @@
+namespace CleanApiStarter.Infrastructure.Database;
+
+public interface IDatabaseConnectionFactory
+{
+ IDbConnection CreateConnection();
+}
+
+public class PostgresConnectionFactory(string connectionString) : IDatabaseConnectionFactory
+{
+ public IDbConnection CreateConnection()
+ {
+ return new NpgsqlConnection(connectionString);
+ }
+}
\ No newline at end of file
diff --git a/src/CleanApiStarter.Infrastructure/DependencyInjection.cs b/src/CleanApiStarter.Infrastructure/DependencyInjection.cs
new file mode 100644
index 0000000..a1b6aa1
--- /dev/null
+++ b/src/CleanApiStarter.Infrastructure/DependencyInjection.cs
@@ -0,0 +1,24 @@
+namespace CleanApiStarter.Infrastructure;
+
+public static class DependencyInjection
+{
+ public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
+ {
+ // Register database connection factory
+ string? connectionString =
+ configuration.GetConnectionString("postgres") ??
+ configuration.GetConnectionString("DefaultConnection");
+
+ if (string.IsNullOrWhiteSpace(connectionString))
+ {
+ throw new InvalidOperationException("Database connection string 'postgres' or 'DefaultConnection' is missing.");
+ }
+
+ services.AddSingleton(_ => new PostgresConnectionFactory(connectionString!));
+
+ // Register repositories
+ services.AddScoped();
+
+ return services;
+ }
+}
diff --git a/src/CleanApiStarter.Infrastructure/GlobalUsings.cs b/src/CleanApiStarter.Infrastructure/GlobalUsings.cs
new file mode 100644
index 0000000..3b36d17
--- /dev/null
+++ b/src/CleanApiStarter.Infrastructure/GlobalUsings.cs
@@ -0,0 +1,14 @@
+global using System.Data;
+global using System.Text.Json;
+
+global using CleanApiStarter.Application.Interfaces;
+global using CleanApiStarter.Domain.Entities;
+global using CleanApiStarter.Infrastructure.Database;
+global using CleanApiStarter.Infrastructure.Repositories;
+
+global using Dapper;
+
+global using Microsoft.Extensions.Configuration;
+global using Microsoft.Extensions.DependencyInjection;
+
+global using Npgsql;
\ No newline at end of file
diff --git a/src/CleanApiStarter.Infrastructure/Repositories/WordRepository.cs b/src/CleanApiStarter.Infrastructure/Repositories/WordRepository.cs
new file mode 100644
index 0000000..d255b5e
--- /dev/null
+++ b/src/CleanApiStarter.Infrastructure/Repositories/WordRepository.cs
@@ -0,0 +1,121 @@
+namespace CleanApiStarter.Infrastructure.Repositories;
+
+public class WordRepository(IDatabaseConnectionFactory connectionFactory) : IWordRepository
+{
+ public async Task AddWordAsync(Word word, CancellationToken cancellationToken)
+ {
+ const string sql =
+ """
+ INSERT INTO words (id, text, meaning, synonyms, usage_example, created_at, updated_at)
+ VALUES (@Id, @Text, @Meaning, @Synonyms::jsonb, @UsageExample, @CreatedAt, @UpdatedAt)
+ RETURNING id
+ """;
+
+ using IDbConnection connection = connectionFactory.CreateConnection();
+
+ var parameters = new
+ {
+ word.Id,
+ word.Text,
+ word.Meaning,
+ Synonyms = JsonSerializer.Serialize(word.Synonyms),
+ word.UsageExample,
+ word.CreatedAt,
+ word.UpdatedAt
+ };
+
+ Guid id = await connection.ExecuteScalarAsync(sql, parameters);
+ return id;
+ }
+
+ public async Task GetWordByIdAsync(Guid id, CancellationToken cancellationToken)
+ {
+ const string sql =
+ """
+ SELECT id, text, meaning, synonyms::text as SynonymsJson, usage_example as UsageExample, created_at as CreatedAt, updated_at as UpdatedAt
+ FROM words
+ WHERE id = @Id
+ """;
+
+ using IDbConnection connection = connectionFactory.CreateConnection();
+ WordRow? word = await connection.QuerySingleOrDefaultAsync(sql, new { Id = id });
+
+ return word?.ToDomain();
+ }
+
+ public async Task> GetAllWordsAsync(CancellationToken cancellationToken)
+ {
+ const string sql =
+ """
+ SELECT id, text, meaning, synonyms::text as SynonymsJson, usage_example as UsageExample, created_at as CreatedAt, updated_at as UpdatedAt
+ FROM words
+ """;
+
+ using IDbConnection connection = connectionFactory.CreateConnection();
+ IEnumerable words = await connection.QueryAsync(sql);
+
+ return words.Select(word => word.ToDomain());
+ }
+
+ public async Task UpdateWordAsync(Word word, CancellationToken cancellationToken)
+ {
+ const string sql =
+ """
+ UPDATE words
+ SET text = @Text,
+ meaning = @Meaning,
+ synonyms = @Synonyms,
+ usage_example = @UsageExample,
+ updated_at = @UpdatedAt
+ WHERE id = @Id
+ """;
+
+ using IDbConnection connection = connectionFactory.CreateConnection();
+ var parameters = new
+ {
+ word.Id,
+ word.Text,
+ word.Meaning,
+ Synonyms = JsonSerializer.Serialize(word.Synonyms),
+ word.UsageExample,
+ word.UpdatedAt
+ };
+
+ int rowsAffected = await connection.ExecuteAsync(sql, parameters);
+ return rowsAffected > 0;
+ }
+
+ public async Task DeleteWordAsync(Guid id, CancellationToken cancellationToken)
+ {
+ const string sql = "DELETE FROM words WHERE id = @Id";
+
+ using IDbConnection connection = connectionFactory.CreateConnection();
+ int rowsAffected = await connection.ExecuteAsync(sql, new { Id = id });
+ return rowsAffected > 0;
+ }
+
+ private sealed class WordRow
+ {
+ public Guid Id { get; init; }
+ public string Text { get; init; } = string.Empty;
+ public string Meaning { get; init; } = string.Empty;
+ public string SynonymsJson { get; init; } = string.Empty;
+ public string UsageExample { get; init; } = string.Empty;
+ public DateTime CreatedAt { get; init; }
+ public DateTime? UpdatedAt { get; init; }
+
+ public Word ToDomain()
+ {
+ return new Word
+ {
+ Id = Id,
+ Text = Text,
+ Meaning = Meaning,
+ Synonyms = JsonSerializer.Deserialize>(SynonymsJson) ?? [],
+ UsageExample = UsageExample,
+ CreatedAt = CreatedAt,
+ UpdatedAt = UpdatedAt
+ };
+ }
+ }
+}
diff --git a/src/Domain/Class1.cs b/src/Domain/Class1.cs
deleted file mode 100644
index dfc5368..0000000
--- a/src/Domain/Class1.cs
+++ /dev/null
@@ -1,6 +0,0 @@
-namespace Domain;
-
-public class Class1
-{
-
-}
diff --git a/src/Domain/Domain.csproj b/src/Domain/Domain.csproj
deleted file mode 100644
index bb23fb7..0000000
--- a/src/Domain/Domain.csproj
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
- net8.0
- enable
- enable
-
-
-
diff --git a/src/Infrastructure/Class1.cs b/src/Infrastructure/Class1.cs
deleted file mode 100644
index 301b928..0000000
--- a/src/Infrastructure/Class1.cs
+++ /dev/null
@@ -1,6 +0,0 @@
-namespace Infrastructure;
-
-public class Class1
-{
-
-}
diff --git a/src/Infrastructure/Infrastructure.csproj b/src/Infrastructure/Infrastructure.csproj
deleted file mode 100644
index bb23fb7..0000000
--- a/src/Infrastructure/Infrastructure.csproj
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
- net8.0
- enable
- enable
-
-
-
diff --git a/tests/CleanApiStarter.UnitTests/CleanApiStarter.UnitTests.csproj b/tests/CleanApiStarter.UnitTests/CleanApiStarter.UnitTests.csproj
new file mode 100644
index 0000000..f36e667
--- /dev/null
+++ b/tests/CleanApiStarter.UnitTests/CleanApiStarter.UnitTests.csproj
@@ -0,0 +1,18 @@
+
+
+ CleanApiStarter.UnitTests
+ CleanApiStarter.UnitTests
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/CleanApiStarter.UnitTests/Controllers/WordsControllerTests.cs b/tests/CleanApiStarter.UnitTests/Controllers/WordsControllerTests.cs
new file mode 100644
index 0000000..356aa00
--- /dev/null
+++ b/tests/CleanApiStarter.UnitTests/Controllers/WordsControllerTests.cs
@@ -0,0 +1,131 @@
+// using CleanApiStarter.Api.Controllers;
+// using CleanApiStarter.Application.Interfaces;
+// using CleanApiStarter.Application.Models;
+// using AutoFixture.Xunit3;
+// using FluentAssertions;
+// using Microsoft.AspNetCore.Mvc;
+// using NSubstitute;
+// using Xunit;
+//
+// namespace CleanApiStarter.UnitTests.Controllers;
+//
+// public class WordsControllerTests
+// {
+// private readonly IWordService _wordService;
+// private readonly WordsController _sut;
+//
+// public WordsControllerTests()
+// {
+// _wordService = Substitute.For();
+// _sut = new WordsController(_wordService);
+// }
+//
+// [Theory, AutoData]
+// public async Task CreateWord_ShouldReturnCreatedAtAction(CreateWordDto wordDto, Guid wordId)
+// {
+// // Arrange
+// _wordService.AddWordAsync(wordDto).Returns(wordId);
+//
+// // Act
+// var result = await _sut.CreateWord(wordDto);
+//
+// // Assert
+// var createdAtActionResult = result.Result.Should().BeOfType().Subject;
+// createdAtActionResult.ActionName.Should().Be(nameof(WordsController.GetWord));
+// createdAtActionResult.RouteValues!.Should().ContainKey("id").WhoseValue.Should().Be(wordId);
+// createdAtActionResult.Value.Should().Be(wordId);
+// }
+//
+// [Theory, AutoData]
+// public async Task GetWord_WhenWordExists_ShouldReturnOkWithWord(Guid wordId, WordDto wordDto)
+// {
+// // Arrange
+// _wordService.GetWordByIdAsync(wordId).Returns(wordDto);
+//
+// // Act
+// var result = await _sut.GetWord(wordId);
+//
+// // Assert
+// var okResult = result.Result.Should().BeOfType().Subject;
+// okResult.Value.Should().Be(wordDto);
+// }
+//
+// [Theory, AutoData]
+// public async Task GetWord_WhenWordDoesNotExist_ShouldReturnNotFound(Guid wordId)
+// {
+// // Arrange
+// _wordService.GetWordByIdAsync(wordId).Returns((WordDto?)null);
+//
+// // Act
+// var result = await _sut.GetWord(wordId);
+//
+// // Assert
+// result.Result.Should().BeOfType();
+// }
+//
+// [Theory, AutoData]
+// public async Task GetAllWords_ShouldReturnOkWithAllWords(List words)
+// {
+// // Arrange
+// _wordService.GetAllWordsAsync().Returns(words);
+//
+// // Act
+// var result = await _sut.GetAllWords();
+//
+// // Assert
+// var okResult = result.Result.Should().BeOfType().Subject;
+// okResult.Value.Should().Be(words);
+// }
+//
+// [Theory, AutoData]
+// public async Task UpdateWord_WhenWordExists_ShouldReturnNoContent(Guid wordId, CreateWordDto updateDto)
+// {
+// // Arrange
+// _wordService.UpdateWordAsync(wordId, updateDto).Returns(true);
+//
+// // Act
+// var result = await _sut.UpdateWord(wordId, updateDto);
+//
+// // Assert
+// result.Should().BeOfType();
+// }
+//
+// [Theory, AutoData]
+// public async Task UpdateWord_WhenWordDoesNotExist_ShouldReturnNotFound(Guid wordId, CreateWordDto updateDto)
+// {
+// // Arrange
+// _wordService.UpdateWordAsync(wordId, updateDto).Returns(false);
+//
+// // Act
+// var result = await _sut.UpdateWord(wordId, updateDto);
+//
+// // Assert
+// result.Should().BeOfType();
+// }
+//
+// [Theory, AutoData]
+// public async Task DeleteWord_WhenWordExists_ShouldReturnNoContent(Guid wordId)
+// {
+// // Arrange
+// _wordService.DeleteWordAsync(wordId).Returns(true);
+//
+// // Act
+// var result = await _sut.DeleteWord(wordId);
+//
+// // Assert
+// result.Should().BeOfType();
+// }
+//
+// [Theory, AutoData]
+// public async Task DeleteWord_WhenWordDoesNotExist_ShouldReturnNotFound(Guid wordId)
+// {
+// // Arrange
+// _wordService.DeleteWordAsync(wordId).Returns(false);
+//
+// // Act
+// var result = await _sut.DeleteWord(wordId);
+//
+// // Assert
+// result.Should().BeOfType();
+// }
+// }
\ No newline at end of file
diff --git a/tests/CleanApiStarter.UnitTests/Repositories/WordRepositoryTests.cs b/tests/CleanApiStarter.UnitTests/Repositories/WordRepositoryTests.cs
new file mode 100644
index 0000000..50d024d
--- /dev/null
+++ b/tests/CleanApiStarter.UnitTests/Repositories/WordRepositoryTests.cs
@@ -0,0 +1,61 @@
+// using System.Data;
+// using AutoFixture.Xunit3;
+// using CleanApiStarter.Domain.Entities;
+// using FluentAssertions;
+// using CleanApiStarter.Infrastructure.Database;
+// using CleanApiStarter.Infrastructure.Repositories;
+// using NSubstitute;
+// using Xunit;
+//
+// namespace CleanApiStarter.UnitTests.Repositories;
+//
+// public class WordRepositoryTests
+// {
+// private readonly IDatabaseConnectionFactory _connectionFactory;
+// private readonly IDbConnection _connection;
+// private readonly WordRepository _sut;
+//
+// public WordRepositoryTests()
+// {
+// _connection = Substitute.For();
+// _connectionFactory = Substitute.For();
+// _connectionFactory.CreateConnection().Returns(_connection);
+//
+// _sut = new WordRepository(_connectionFactory);
+// }
+//
+// [Theory, AutoData]
+// public async Task AddWordAsync_ShouldOpenConnectionAndExecuteCommand(Word word)
+// {
+// // This test is a basic structure to show how to mock Dapper
+// // Full implementation would require more complex mocking of connection and command
+//
+// // Arrange
+// var expectedId = word.Id;
+//
+// // Act
+// await _sut.AddWordAsync(word);
+//
+// // Assert
+// _connectionFactory.Received(1).CreateConnection();
+// }
+//
+// [Theory, AutoData]
+// public async Task GetWordByIdAsync_ShouldOpenConnectionAndExecuteQuery(Guid id)
+// {
+// // This test is a basic structure to show how to mock Dapper
+// // Full implementation would require more complex mocking of connection and query
+//
+// // Arrange
+//
+// // Act
+// await _sut.GetWordByIdAsync(id);
+//
+// // Assert
+// _connectionFactory.Received(1).CreateConnection();
+// }
+//
+// // Additional tests would follow the same pattern
+// // For a real implementation, you would need to mock Dapper's extension methods
+// // which is more complex and may require a test database or more sophisticated mocking
+// }
\ No newline at end of file
diff --git a/tests/CleanApiStarter.UnitTests/Services/WordServiceTests.cs b/tests/CleanApiStarter.UnitTests/Services/WordServiceTests.cs
new file mode 100644
index 0000000..601e4a5
--- /dev/null
+++ b/tests/CleanApiStarter.UnitTests/Services/WordServiceTests.cs
@@ -0,0 +1,139 @@
+// using CleanApiStarter.Application.Interfaces;
+// using CleanApiStarter.Application.Models;
+// using CleanApiStarter.Application.Services;
+//
+// using CleanApiStarter.Domain.Entities;
+//
+// using NSubstitute;
+//
+// using Shouldly;
+//
+// using Xunit;
+//
+// namespace CleanApiStarter.UnitTests.Services;
+//
+// public class WordServiceTests
+// {
+// private readonly IWordRepository _wordRepository;
+// private readonly WordService _sut;
+//
+// public WordServiceTests()
+// {
+// _wordRepository = Substitute.For();
+// _sut = new WordService(_wordRepository);
+// }
+//
+// [Theory, AutoData]
+// public async Task AddWordAsync_ShouldMapDtoToDomainAndCallRepository(CreateWordDto wordDto)
+// {
+// // Arrange
+// var expectedId = Guid.NewGuid();
+// _wordRepository.AddWordAsync(Arg.Any()).Returns(expectedId);
+//
+// // Act
+// var result = await _sut.AddWordAsync(wordDto);
+//
+// // Assert
+// result.ShouldBe(expectedId);
+// await _wordRepository.Received(1).AddWordAsync(Arg.Is(w =>
+// w.Text == wordDto.Text &&
+// w.Meaning == wordDto.Meaning &&
+// w.Synonyms.SequenceEqual(wordDto.Synonyms) &&
+// w.UsageExample == wordDto.UsageExample));
+// }
+//
+// [Theory, AutoData]
+// public async Task GetWordByIdAsync_WhenWordExists_ShouldReturnMappedDto(Guid id, Word word)
+// {
+// // Arrange
+// _wordRepository.GetWordByIdAsync(id).Returns(word);
+//
+// // Act
+// var result = await _sut.GetWordByIdAsync(id);
+//
+// // Assert
+// result.Should().NotBeNull();
+// result!.Id.Should().Be(word.Id);
+// result.Text.Should().Be(word.Text);
+// result.Meaning.Should().Be(word.Meaning);
+// result.Synonyms.Should().BeEquivalentTo(word.Synonyms);
+// result.UsageExample.Should().Be(word.UsageExample);
+// }
+//
+// [Theory, AutoData]
+// public async Task GetWordByIdAsync_WhenWordDoesNotExist_ShouldReturnNull(Guid id)
+// {
+// // Arrange
+// _wordRepository.GetWordByIdAsync(id).Returns((Word?)null);
+//
+// // Act
+// var result = await _sut.GetWordByIdAsync(id);
+//
+// // Assert
+// result.Should().BeNull();
+// }
+//
+// [Theory, AutoData]
+// public async Task GetAllWordsAsync_ShouldReturnMappedDtos(List words)
+// {
+// // Arrange
+// _wordRepository.GetAllWordsAsync().Returns(words);
+//
+// // Act
+// var result = await _sut.GetAllWordsAsync();
+//
+// // Assert
+// result.Should().HaveCount(words.Count);
+// result.Select(dto => dto.Id).Should().BeEquivalentTo(words.Select(w => w.Id));
+// }
+//
+// [Theory, AutoData]
+// public async Task UpdateWordAsync_WhenWordExists_ShouldUpdateAndReturnTrue(Guid id, CreateWordDto updateDto, Word existingWord)
+// {
+// // Arrange
+// existingWord.Id = id;
+// _wordRepository.GetWordByIdAsync(id).Returns(existingWord);
+// _wordRepository.UpdateWordAsync(Arg.Any()).Returns(true);
+//
+// // Act
+// var result = await _sut.UpdateWordAsync(id, updateDto);
+//
+// // Assert
+// result.Should().BeTrue();
+// await _wordRepository.Received(1).UpdateWordAsync(Arg.Is(w =>
+// w.Id == id &&
+// w.Text == updateDto.Text &&
+// w.Meaning == updateDto.Meaning &&
+// w.Synonyms.SequenceEqual(updateDto.Synonyms) &&
+// w.UsageExample == updateDto.UsageExample &&
+// w.UpdatedAt != null));
+// }
+//
+// [Theory, AutoData]
+// public async Task UpdateWordAsync_WhenWordDoesNotExist_ShouldReturnFalse(Guid id, CreateWordDto updateDto)
+// {
+// // Arrange
+// _wordRepository.GetWordByIdAsync(id).Returns((Word?)null);
+//
+// // Act
+// var result = await _sut.UpdateWordAsync(id, updateDto);
+//
+// // Assert
+// result.Should().BeFalse();
+// await _wordRepository.DidNotReceive().UpdateWordAsync(Arg.Any());
+// }
+//
+// [Theory, AutoData]
+// public async Task DeleteWordAsync_ShouldCallRepositoryAndReturnResult(Guid id, bool expectedResult)
+// {
+// // Arrange
+// _wordRepository.DeleteWordAsync(id).Returns(expectedResult);
+//
+// // Act
+// var result = await _sut.DeleteWordAsync(id);
+//
+// // Assert
+// result.Should().Be(expectedResult);
+// await _wordRepository.Received(1).DeleteWordAsync(id);
+// }
+// }
\ No newline at end of file
diff --git a/tests/UnitTests/UnitTest1.cs b/tests/UnitTests/UnitTest1.cs
deleted file mode 100644
index 791524d..0000000
--- a/tests/UnitTests/UnitTest1.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-namespace UnitTests;
-
-public class UnitTest1
-{
- [Fact]
- public void Test1()
- {
-
- }
-}
\ No newline at end of file
diff --git a/tests/UnitTests/UnitTests.csproj b/tests/UnitTests/UnitTests.csproj
deleted file mode 100644
index a1d45f6..0000000
--- a/tests/UnitTests/UnitTests.csproj
+++ /dev/null
@@ -1,23 +0,0 @@
-
-
-
- net8.0
- enable
- enable
-
- false
- true
-
-
-
-
-
-
-
-
-
-
-
-
-
-
From d911cc4da87b135bc73f3e30cab25d0ee2d216ff Mon Sep 17 00:00:00 2001
From: cbjpdev <42979311+cstacklab@users.noreply.github.com>
Date: Sun, 26 Apr 2026 12:00:22 +0800
Subject: [PATCH 04/33] introduce versioned endpoint groups, configuration
project, and runtime defaults for CleanApiStarter API
---
AGENTS.md | 23 ++-
CleanApiStarter.slnx | 1 +
Directory.Packages.props | 8 +-
README.md | 9 +-
src/CleanApiStarter.Api/Api.http | 10 +-
.../{WordEndpoints.cs => V1/Words.cs} | 33 ++--
src/CleanApiStarter.Api/Endpoints/V2/Words.cs | 27 +++
src/CleanApiStarter.Api/GlobalUsings.cs | 4 +-
src/CleanApiStarter.Api/Program.cs | 21 ++-
.../appsettings.Development.json | 8 +-
src/CleanApiStarter.AppHost/Program.cs | 32 ++--
.../GlobalUsings.cs | 10 +-
.../Interfaces/IWordService.cs | 16 +-
.../Services/WordService.cs | 174 +++++++++---------
.../AspNetCoreDefaultServices.cs | 52 ++++++
.../CleanApiStarter.AspNetCore.csproj | 1 +
src/CleanApiStarter.AspNetCore/Extensions.cs | 119 +++++-------
.../GlobalUsings.cs | 9 +
.../IEndpointGroup.cs | 10 +
.../OpenTelemetryDefaults.cs | 41 +++++
.../ProblemDetailsExceptionHandler.cs | 54 ++++++
.../WebApplicationExtensions.cs | 39 ++++
.../AppSettings.cs | 8 +
.../CleanApiStarter.Configuration.csproj | 15 ++
.../ConnectionStringSettings.cs | 7 +
.../GlobalUsings.cs | 5 +
.../OptionsRegistrationExtensions.cs | 19 ++
.../CleanApiStarter.Infrastructure.csproj | 1 +
.../Database/DatabaseConnectionFactory.cs | 14 +-
.../DependencyInjection.cs | 26 +--
.../GlobalUsings.cs | 28 +--
.../Repositories/WordRepository.cs | 12 +-
32 files changed, 553 insertions(+), 283 deletions(-)
rename src/CleanApiStarter.Api/Endpoints/{WordEndpoints.cs => V1/Words.cs} (68%)
create mode 100644 src/CleanApiStarter.Api/Endpoints/V2/Words.cs
create mode 100644 src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs
create mode 100644 src/CleanApiStarter.AspNetCore/IEndpointGroup.cs
create mode 100644 src/CleanApiStarter.AspNetCore/OpenTelemetryDefaults.cs
create mode 100644 src/CleanApiStarter.AspNetCore/ProblemDetailsExceptionHandler.cs
create mode 100644 src/CleanApiStarter.AspNetCore/WebApplicationExtensions.cs
create mode 100644 src/CleanApiStarter.Configuration/AppSettings.cs
create mode 100644 src/CleanApiStarter.Configuration/CleanApiStarter.Configuration.csproj
create mode 100644 src/CleanApiStarter.Configuration/ConnectionStringSettings.cs
create mode 100644 src/CleanApiStarter.Configuration/GlobalUsings.cs
create mode 100644 src/CleanApiStarter.Configuration/OptionsRegistrationExtensions.cs
diff --git a/AGENTS.md b/AGENTS.md
index 8573ebd..fe52dad 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -9,6 +9,7 @@ This repository is a Clean Architecture API starter template named `CleanApiStar
- Keep project names fully qualified:
- `CleanApiStarter.Api`
- `CleanApiStarter.Application`
+ - `CleanApiStarter.Configuration`
- `CleanApiStarter.Domain`
- `CleanApiStarter.Infrastructure`
- `CleanApiStarter.AppHost`
@@ -20,6 +21,7 @@ This repository is a Clean Architecture API starter template named `CleanApiStar
- Keep clean architecture application layers under the `/src/` solution folder:
- API
- Application
+ - Configuration
- Domain
- Infrastructure
- Keep Aspire/runtime support projects under the `/src/Common/` solution folder in `CleanApiStarter.slnx`:
@@ -33,8 +35,9 @@ This repository is a Clean Architecture API starter template named `CleanApiStar
- Dependencies point inward:
- `Domain` references nothing.
- `Application` references `Domain`.
- - `Infrastructure` references `Application`.
- - `Api` composes `Application`, `Infrastructure`, and `AspNetCore`.
+ - `Configuration` references no application layers and contains only plain options classes plus options registration helpers.
+ - `Infrastructure` references `Application` and `Configuration`.
+ - `Api` composes `Application`, `Infrastructure`, `Configuration`, and `AspNetCore`.
- Keep repository interfaces in `Application`, not `Domain`.
- Keep database implementation details in `Infrastructure`.
- Do not move `IDatabaseConnectionFactory` into `Application` or `Domain`.
@@ -59,7 +62,7 @@ This repository is a Clean Architecture API starter template named `CleanApiStar
- Use a single Postgres database: `postgres`.
- Do not create or reference a separate `wordlibrary` database.
-- AppHost should expose the API connection as `ConnectionStrings__postgres`.
+- AppHost should expose the API connection from the `postgres` resource, and application settings should read it through `ConnectionStrings:Postgres`.
- `docker-compose.yml` should use `POSTGRES_DB=postgres`.
- Database schema scripts live in `database/migrations`, for example:
- `database/migrations/V001__create_words_table.sql`
@@ -76,9 +79,12 @@ This repository is a Clean Architecture API starter template named `CleanApiStar
- Keep `CleanApiStarter.AspNetCore`.
- It centralizes Aspire-friendly runtime defaults:
- OpenTelemetry traces, metrics, logs, and OTLP export
+ - problem-details exception handling
+ - `/version` endpoint
- health endpoints
- service discovery
- default HTTP client resilience
+ - security defaults such as removing the Kestrel `Server` response header
- API `Program.cs` should stay small and call:
- `builder.AddAspNetCoreDefaults();`
- `app.UseAspNetCoreDefaults();`
@@ -86,11 +92,20 @@ This repository is a Clean Architecture API starter template named `CleanApiStar
- Shared middleware such as HTTP request logging belongs in `CleanApiStarter.AspNetCore`, not duplicated inside each API project.
- Keep OpenTelemetry logs configured to include scopes, formatted messages, and parsed state values so structured message-template properties show up in Aspire.
- Use structured logging message templates instead of interpolated log strings. Prefer stable property names like `{WordId}` and `{WordCount}`.
+- Register root settings once with `AddAppSettings(builder.Configuration)`, then inject `AppSettings` directly when services need configuration values.
+- Do not add generic options registration helpers until the template has multiple real options sections that need them.
+- Do not create a broad `Shared` project. Keep cross-project settings in `CleanApiStarter.Configuration`.
+- Keep dependency injection validation enabled with `ValidateOnBuild` and `ValidateScopes`.
## API Style
- Prefer Minimal APIs for this template.
-- Keep `Program.cs` small by placing route groups in endpoint mapping classes such as `Api/Endpoints/WordEndpoints.cs`.
+- Keep `Program.cs` small by placing route groups in endpoint group classes under version folders such as `Api/Endpoints/V1/Words.cs`.
+- Endpoint group classes should implement `IEndpointGroup` from `CleanApiStarter.AspNetCore` and be mapped through `app.MapEndpoints(Assembly.GetExecutingAssembly());`.
+- Use built-in Minimal API mapping methods with explicit `.WithName(...)`; do not add custom `MapGet`/`MapPost` overloads that shadow framework methods.
+- API versions are selected with the required `X-Api-Version` request header.
+- Endpoint groups should declare `MajorVersion` to match their folder, for example `V1` uses `1` and `V2` uses `2`.
+- Endpoint names must be globally unique across versions. Prefer names suffixed with the version, such as `GetAllWordsV1` and `GetAllWordsV2`.
- Do not reintroduce MVC controllers unless the template intentionally changes direction.
## Packages
diff --git a/CleanApiStarter.slnx b/CleanApiStarter.slnx
index 9b35a61..60e10b1 100644
--- a/CleanApiStarter.slnx
+++ b/CleanApiStarter.slnx
@@ -21,6 +21,7 @@
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 2437245..2db7419 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -8,20 +8,24 @@
10.0.0
-
+
+
+
+
+
@@ -38,4 +42,4 @@
-
\ No newline at end of file
+
diff --git a/README.md b/README.md
index 4c890cc..5214914 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,9 @@
A Clean Architecture API starter template for .NET, Aspire, PostgreSQL, and OpenTelemetry.
-The API uses Minimal APIs with endpoint mapping classes so `Program.cs` stays small while routes remain easy to find.
+The API uses Minimal APIs with endpoint group classes. `CleanApiStarter.AspNetCore` discovers endpoint groups from the API assembly so `Program.cs` stays small while routes remain easy to find.
+
+API versions are selected with the required `X-Api-Version` request header. Endpoint groups live under version folders such as `Endpoints/V1` and `Endpoints/V2`.
## Learn OpenTelemetry with .NET Aspire
@@ -21,7 +23,7 @@ Open the Aspire dashboard URL printed in the terminal. The AppHost starts:
- `postgres`: the API database
- `pgAdmin`: a browser UI for inspecting PostgreSQL
-The API receives its connection string from Aspire as `ConnectionStrings__postgres`. Running the API directly still uses `ConnectionStrings:DefaultConnection` from `src/CleanApiStarter.Api/appsettings.Development.json`, which matches the existing `docker-compose.yml`.
+The API receives its `postgres` connection string from Aspire, which binds to `ConnectionStrings:Postgres`. Running the API directly uses `ConnectionStrings:Postgres` from `src/CleanApiStarter.Api/appsettings.Development.json`, which matches the existing `docker-compose.yml`.
Database scripts live in `database`. Aspire and Docker Compose copy `database/migrations` into the Postgres container init folder. Docker runs them only when the Postgres data directory is created for the first time. Because the AppHost uses the persistent `clean-api-starter-postgres-data` volume, delete that Docker volume if you need to replay the scripts from scratch.
@@ -35,6 +37,7 @@ After the dashboard is running, send requests to the API through Scalar or anoth
- Request logs show method, path, response status code, and duration without logging request or response bodies. `CleanApiStarter.AspNetCore` keeps the `Microsoft.AspNetCore.HttpLogging` category at `Information` even when broader ASP.NET Core logs are filtered to `Warning`.
- Traces show incoming HTTP requests and PostgreSQL commands from Npgsql.
- Metrics show ASP.NET Core, HTTP client, and .NET runtime measurements.
+- `/version` exposes the running application version.
- Health checks expose `/health` and `/alive` in Development.
### Coding Conventions
@@ -43,6 +46,8 @@ Cancellation tokens are explicit at application and infrastructure boundaries. D
Use structured logging message templates instead of interpolated log strings. Prefer stable property names such as `{WordId}` or `{WordCount}` so Aspire and OpenTelemetry can index and filter them.
+Configuration classes live in `CleanApiStarter.Configuration`. Register root settings once with `AddAppSettings(builder.Configuration)`, then inject `AppSettings` directly where configuration values are needed.
+
### Run with Docker Compose Only
If you want to run PostgreSQL without Aspire:
diff --git a/src/CleanApiStarter.Api/Api.http b/src/CleanApiStarter.Api/Api.http
index f420003..2bef6bf 100644
--- a/src/CleanApiStarter.Api/Api.http
+++ b/src/CleanApiStarter.Api/Api.http
@@ -1,12 +1,14 @@
-@CleanApiStarter_Api_HostAddress = http://localhost:5219
+@CleanApiStarter_Api_HostAddress = https://localhost:7285
GET {{CleanApiStarter_Api_HostAddress}}/api/words
Accept: application/json
+X-Api-Version: 1.0
###
POST {{CleanApiStarter_Api_HostAddress}}/api/words
Content-Type: application/json
+X-Api-Version: 1.0
{
"text": "observable",
@@ -16,3 +18,9 @@ Content-Type: application/json
}
###
+
+GET {{CleanApiStarter_Api_HostAddress}}/api/words
+Accept: application/json
+X-Api-Version: 2.0
+
+###
diff --git a/src/CleanApiStarter.Api/Endpoints/WordEndpoints.cs b/src/CleanApiStarter.Api/Endpoints/V1/Words.cs
similarity index 68%
rename from src/CleanApiStarter.Api/Endpoints/WordEndpoints.cs
rename to src/CleanApiStarter.Api/Endpoints/V1/Words.cs
index e5fdfb7..9aeb17c 100644
--- a/src/CleanApiStarter.Api/Endpoints/WordEndpoints.cs
+++ b/src/CleanApiStarter.Api/Endpoints/V1/Words.cs
@@ -1,28 +1,27 @@
-namespace CleanApiStarter.Api.Endpoints;
+namespace CleanApiStarter.Api.Endpoints.V1;
-public static class WordEndpoints
+public sealed class Words : IEndpointGroup
{
- public static IEndpointRouteBuilder MapWordEndpoints(this IEndpointRouteBuilder endpoints)
- {
- RouteGroupBuilder group = endpoints.MapGroup("/api/words")
- .WithTags("Words");
+ public static int MajorVersion => 1;
- group.MapPost("/", CreateWord)
- .WithName(nameof(CreateWord));
+ public static string RoutePrefix => "/api/words";
- group.MapGet("/{id:guid}", GetWord)
- .WithName(nameof(GetWord));
+ public static void Map(RouteGroupBuilder groupBuilder)
+ {
+ groupBuilder.MapPost("/", CreateWord)
+ .WithName("CreateWordV1");
- group.MapGet("/", GetAllWords)
- .WithName(nameof(GetAllWords));
+ groupBuilder.MapGet("/", GetAllWords)
+ .WithName("GetAllWordsV1");
- group.MapPut("/{id:guid}", UpdateWord)
- .WithName(nameof(UpdateWord));
+ groupBuilder.MapGet("/{id:guid}", GetWord)
+ .WithName("GetWordV1");
- group.MapDelete("/{id:guid}", DeleteWord)
- .WithName(nameof(DeleteWord));
+ groupBuilder.MapPut("/{id:guid}", UpdateWord)
+ .WithName("UpdateWordV1");
- return endpoints;
+ groupBuilder.MapDelete("/{id:guid}", DeleteWord)
+ .WithName("DeleteWordV1");
}
private static async Task CreateWord(
diff --git a/src/CleanApiStarter.Api/Endpoints/V2/Words.cs b/src/CleanApiStarter.Api/Endpoints/V2/Words.cs
new file mode 100644
index 0000000..c869ab6
--- /dev/null
+++ b/src/CleanApiStarter.Api/Endpoints/V2/Words.cs
@@ -0,0 +1,27 @@
+namespace CleanApiStarter.Api.Endpoints.V2;
+
+public sealed class Words : IEndpointGroup
+{
+ public static int MajorVersion => 2;
+
+ public static string RoutePrefix => "/api/words";
+
+ public static void Map(RouteGroupBuilder groupBuilder)
+ {
+ groupBuilder.MapGet("/", GetAllWords)
+ .WithName("GetAllWordsV2");
+ }
+
+ private static async Task GetAllWords(
+ IWordService wordService,
+ CancellationToken cancellationToken)
+ {
+ IEnumerable words = await wordService.GetAllWordsAsync(cancellationToken);
+
+ return Results.Ok(new
+ {
+ ApiVersion = "2.0",
+ Items = words
+ });
+ }
+}
diff --git a/src/CleanApiStarter.Api/GlobalUsings.cs b/src/CleanApiStarter.Api/GlobalUsings.cs
index 30dfefd..01ef137 100644
--- a/src/CleanApiStarter.Api/GlobalUsings.cs
+++ b/src/CleanApiStarter.Api/GlobalUsings.cs
@@ -1,10 +1,12 @@
// Global using directives
-global using CleanApiStarter.Api.Endpoints;
+global using System.Reflection;
+
global using CleanApiStarter.Application;
global using CleanApiStarter.Application.Interfaces;
global using CleanApiStarter.Application.Models;
global using CleanApiStarter.AspNetCore;
+global using CleanApiStarter.Configuration;
global using CleanApiStarter.Infrastructure;
global using Microsoft.AspNetCore.Http;
diff --git a/src/CleanApiStarter.Api/Program.cs b/src/CleanApiStarter.Api/Program.cs
index 1f4ec8e..05392a3 100644
--- a/src/CleanApiStarter.Api/Program.cs
+++ b/src/CleanApiStarter.Api/Program.cs
@@ -1,12 +1,13 @@
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
-builder.AddAspNetCoreDefaults();
-
+builder.AddAspNetCoreDefaults();
+
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddOpenApi();
+builder.Services.AddAppSettings(builder.Configuration);
builder.Services.AddApplication();
-builder.Services.AddInfrastructure(builder.Configuration);
+builder.Services.AddInfrastructure();
WebApplication app = builder.Build();
@@ -15,11 +16,11 @@
{
app.MapOpenApi();
app.MapScalarApiReference();
-}
-
-app.UseAspNetCoreDefaults();
-app.UseHttpsRedirection();
-app.MapWordEndpoints();
-app.MapDefaultEndpoints();
-
+}
+
+app.UseAspNetCoreDefaults();
+app.UseHttpsRedirection();
+app.MapEndpoints(Assembly.GetExecutingAssembly());
+app.MapDefaultEndpoints();
+
app.Run();
diff --git a/src/CleanApiStarter.Api/appsettings.Development.json b/src/CleanApiStarter.Api/appsettings.Development.json
index 7de2b6c..1690903 100644
--- a/src/CleanApiStarter.Api/appsettings.Development.json
+++ b/src/CleanApiStarter.Api/appsettings.Development.json
@@ -1,11 +1,5 @@
{
"ConnectionStrings": {
- "DefaultConnection": "Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=postgres"
- },
- "Logging": {
- "LogLevel": {
- "Default": "Information",
- "Microsoft.AspNetCore": "Warning"
- }
+ "Postgres": "Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=postgres"
}
}
diff --git a/src/CleanApiStarter.AppHost/Program.cs b/src/CleanApiStarter.AppHost/Program.cs
index 823eb18..aa0a117 100644
--- a/src/CleanApiStarter.AppHost/Program.cs
+++ b/src/CleanApiStarter.AppHost/Program.cs
@@ -1,19 +1,19 @@
IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(args);
-string databasePath = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, "..", "..", "database"));
-string databaseMigrationsPath = Path.Combine(databasePath, "migrations");
-
-IResourceBuilder postgresServer = builder.AddPostgres("postgres-server")
- .WithImageTag("latest")
- .WithVolume("clean-api-starter-postgres-data", "/var/lib/postgresql")
- .WithInitFiles(databaseMigrationsPath)
- .WithPgAdmin();
-
-IResourceBuilder database = postgresServer.AddDatabase("postgres")
- .WithCreationScript("SELECT 1;");
-
-builder.AddProject("api")
- .WithReference(database)
- .WaitFor(database);
+string databasePath = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, "..", "..", "database"));
+string databaseMigrationsPath = Path.Combine(databasePath, "migrations");
-builder.Build().Run();
+IResourceBuilder postgresServer = builder.AddPostgres("postgres-server")
+ .WithImageTag("latest")
+ .WithVolume("clean-api-starter-postgres-data", "/var/lib/postgresql")
+ .WithInitFiles(databaseMigrationsPath)
+ .WithPgAdmin();
+
+IResourceBuilder database = postgresServer.AddDatabase("postgres")
+ .WithCreationScript("SELECT 1;");
+
+builder.AddProject("api")
+ .WithReference(database)
+ .WaitFor(database);
+
+builder.Build().Run();
\ No newline at end of file
diff --git a/src/CleanApiStarter.Application/GlobalUsings.cs b/src/CleanApiStarter.Application/GlobalUsings.cs
index f169a6b..2e2a517 100644
--- a/src/CleanApiStarter.Application/GlobalUsings.cs
+++ b/src/CleanApiStarter.Application/GlobalUsings.cs
@@ -1,7 +1,7 @@
global using CleanApiStarter.Application.Interfaces;
global using CleanApiStarter.Application.Models;
-global using CleanApiStarter.Application.Services;
-global using CleanApiStarter.Domain.Entities;
-
-global using Microsoft.Extensions.DependencyInjection;
-global using Microsoft.Extensions.Logging;
+global using CleanApiStarter.Application.Services;
+global using CleanApiStarter.Domain.Entities;
+
+global using Microsoft.Extensions.DependencyInjection;
+global using Microsoft.Extensions.Logging;
\ No newline at end of file
diff --git a/src/CleanApiStarter.Application/Interfaces/IWordService.cs b/src/CleanApiStarter.Application/Interfaces/IWordService.cs
index 46f0c59..4ff4a63 100644
--- a/src/CleanApiStarter.Application/Interfaces/IWordService.cs
+++ b/src/CleanApiStarter.Application/Interfaces/IWordService.cs
@@ -1,10 +1,10 @@
namespace CleanApiStarter.Application.Interfaces;
-public interface IWordService
-{
- Task AddWordAsync(CreateWordDto wordDto, CancellationToken cancellationToken);
- Task GetWordByIdAsync(Guid id, CancellationToken cancellationToken);
- Task> GetAllWordsAsync(CancellationToken cancellationToken);
- Task UpdateWordAsync(Guid id, CreateWordDto wordDto, CancellationToken cancellationToken);
- Task DeleteWordAsync(Guid id, CancellationToken cancellationToken);
-}
+public interface IWordService
+{
+ Task AddWordAsync(CreateWordDto wordDto, CancellationToken cancellationToken);
+ Task GetWordByIdAsync(Guid id, CancellationToken cancellationToken);
+ Task> GetAllWordsAsync(CancellationToken cancellationToken);
+ Task UpdateWordAsync(Guid id, CreateWordDto wordDto, CancellationToken cancellationToken);
+ Task DeleteWordAsync(Guid id, CancellationToken cancellationToken);
+}
\ No newline at end of file
diff --git a/src/CleanApiStarter.Application/Services/WordService.cs b/src/CleanApiStarter.Application/Services/WordService.cs
index ffa6d09..9f1b7fb 100644
--- a/src/CleanApiStarter.Application/Services/WordService.cs
+++ b/src/CleanApiStarter.Application/Services/WordService.cs
@@ -1,95 +1,95 @@
-namespace CleanApiStarter.Application.Services;
-
-public class WordService(IWordRepository wordRepository, ILogger logger) : IWordService
-{
- public async Task AddWordAsync(CreateWordDto wordDto, CancellationToken cancellationToken)
- {
- Word word = new()
- {
+namespace CleanApiStarter.Application.Services;
+
+public class WordService(IWordRepository wordRepository, ILogger logger) : IWordService
+{
+ public async Task AddWordAsync(CreateWordDto wordDto, CancellationToken cancellationToken)
+ {
+ Word word = new()
+ {
Id = Guid.NewGuid(),
Text = wordDto.Text,
Meaning = wordDto.Meaning,
Synonyms = wordDto.Synonyms,
UsageExample = wordDto.UsageExample,
- CreatedAt = DateTime.UtcNow
- };
-
- logger.LogInformation(
- "Creating word {WordId} with {SynonymCount} synonyms",
- word.Id,
- word.Synonyms.Count);
-
- Guid wordId = await wordRepository.AddWordAsync(word, cancellationToken);
-
- logger.LogInformation("Created word {WordId}", wordId);
-
- return wordId;
- }
-
- public async Task GetWordByIdAsync(Guid id, CancellationToken cancellationToken)
- {
- Word? word = await wordRepository.GetWordByIdAsync(id, cancellationToken);
-
- if (word == null)
- {
- logger.LogWarning("Word {WordId} was not found", id);
- return null;
- }
-
- logger.LogInformation("Retrieved word {WordId}", id);
-
- return MapToDto(word);
- }
-
- public async Task> GetAllWordsAsync(CancellationToken cancellationToken)
- {
- IEnumerable words = await wordRepository.GetAllWordsAsync(cancellationToken);
- Word[] wordList = words.ToArray();
-
- logger.LogInformation("Retrieved {WordCount} words", wordList.Length);
-
- return wordList.Select(MapToDto);
- }
-
- public async Task UpdateWordAsync(Guid id, CreateWordDto wordDto, CancellationToken cancellationToken)
- {
- Word? existingWord = await wordRepository.GetWordByIdAsync(id, cancellationToken);
- if (existingWord == null)
- {
- logger.LogWarning("Word {WordId} could not be updated because it was not found", id);
- return false;
- }
-
- existingWord.Text = wordDto.Text;
- existingWord.Meaning = wordDto.Meaning;
+ CreatedAt = DateTime.UtcNow
+ };
+
+ logger.LogInformation(
+ "Creating word {WordId} with {SynonymCount} synonyms",
+ word.Id,
+ word.Synonyms.Count);
+
+ Guid wordId = await wordRepository.AddWordAsync(word, cancellationToken);
+
+ logger.LogInformation("Created word {WordId}", wordId);
+
+ return wordId;
+ }
+
+ public async Task GetWordByIdAsync(Guid id, CancellationToken cancellationToken)
+ {
+ Word? word = await wordRepository.GetWordByIdAsync(id, cancellationToken);
+
+ if (word == null)
+ {
+ logger.LogWarning("Word {WordId} was not found", id);
+ return null;
+ }
+
+ logger.LogInformation("Retrieved word {WordId}", id);
+
+ return MapToDto(word);
+ }
+
+ public async Task> GetAllWordsAsync(CancellationToken cancellationToken)
+ {
+ IEnumerable words = await wordRepository.GetAllWordsAsync(cancellationToken);
+ Word[] wordList = words.ToArray();
+
+ logger.LogInformation("Retrieved {WordCount} words", wordList.Length);
+
+ return wordList.Select(MapToDto);
+ }
+
+ public async Task UpdateWordAsync(Guid id, CreateWordDto wordDto, CancellationToken cancellationToken)
+ {
+ Word? existingWord = await wordRepository.GetWordByIdAsync(id, cancellationToken);
+ if (existingWord == null)
+ {
+ logger.LogWarning("Word {WordId} could not be updated because it was not found", id);
+ return false;
+ }
+
+ existingWord.Text = wordDto.Text;
+ existingWord.Meaning = wordDto.Meaning;
existingWord.Synonyms = wordDto.Synonyms;
- existingWord.UsageExample = wordDto.UsageExample;
- existingWord.UpdatedAt = DateTime.UtcNow;
-
- bool updated = await wordRepository.UpdateWordAsync(existingWord, cancellationToken);
-
- logger.LogInformation(
- "Updated word {WordId}; success: {UpdateSucceeded}",
- id,
- updated);
-
- return updated;
- }
-
- public async Task DeleteWordAsync(Guid id, CancellationToken cancellationToken)
- {
- bool deleted = await wordRepository.DeleteWordAsync(id, cancellationToken);
-
- if (!deleted)
- {
- logger.LogWarning("Word {WordId} could not be deleted because it was not found", id);
- return false;
- }
-
- logger.LogInformation("Deleted word {WordId}", id);
-
- return true;
- }
+ existingWord.UsageExample = wordDto.UsageExample;
+ existingWord.UpdatedAt = DateTime.UtcNow;
+
+ bool updated = await wordRepository.UpdateWordAsync(existingWord, cancellationToken);
+
+ logger.LogInformation(
+ "Updated word {WordId}; success: {UpdateSucceeded}",
+ id,
+ updated);
+
+ return updated;
+ }
+
+ public async Task DeleteWordAsync(Guid id, CancellationToken cancellationToken)
+ {
+ bool deleted = await wordRepository.DeleteWordAsync(id, cancellationToken);
+
+ if (!deleted)
+ {
+ logger.LogWarning("Word {WordId} could not be deleted because it was not found", id);
+ return false;
+ }
+
+ logger.LogInformation("Deleted word {WordId}", id);
+
+ return true;
+ }
private static WordDto MapToDto(Word word)
{
@@ -102,4 +102,4 @@ private static WordDto MapToDto(Word word)
UsageExample = word.UsageExample
};
}
-}
+}
\ No newline at end of file
diff --git a/src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs b/src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs
new file mode 100644
index 0000000..645a49b
--- /dev/null
+++ b/src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs
@@ -0,0 +1,52 @@
+namespace CleanApiStarter.AspNetCore;
+
+public static partial class Extensions
+{
+ private static void AddSecurityDefaults(this IHostApplicationBuilder builder)
+ {
+ builder.Services.Configure(
+ options => options.AddServerHeader = false);
+ }
+
+ private static void AddProblemDetailsDefaults(this IHostApplicationBuilder builder)
+ {
+ builder.Services.AddExceptionHandler();
+ builder.Services.AddProblemDetails();
+ }
+
+ private static void AddApiVersioningDefaults(this IHostApplicationBuilder builder)
+ {
+ builder.Services.AddApiVersioning(options =>
+ {
+ options.DefaultApiVersion = new ApiVersion(1);
+ options.AssumeDefaultVersionWhenUnspecified = false;
+ options.ReportApiVersions = true;
+ options.ApiVersionReader = new HeaderApiVersionReader("X-Api-Version");
+ });
+ }
+
+ private static void AddHttpLoggingDefaults(this IHostApplicationBuilder builder)
+ {
+ builder.Logging.AddFilter("Microsoft.AspNetCore.HttpLogging", LogLevel.Information);
+
+ builder.Services.AddHttpLogging(options =>
+ {
+ options.LoggingFields =
+ HttpLoggingFields.RequestMethod |
+ HttpLoggingFields.RequestPath |
+ HttpLoggingFields.ResponseStatusCode |
+ HttpLoggingFields.Duration;
+ });
+ }
+
+ private static void AddServiceDiscoveryDefaults(this IHostApplicationBuilder builder)
+ {
+ builder.Services.AddServiceDiscovery();
+
+ builder.Services.ConfigureHttpClientDefaults(http =>
+ {
+ http.AddStandardResilienceHandler();
+ http.AddServiceDiscovery();
+ });
+ }
+}
diff --git a/src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj b/src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj
index 73f91c4..9247916 100644
--- a/src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj
+++ b/src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj
@@ -7,6 +7,7 @@
+
diff --git a/src/CleanApiStarter.AspNetCore/Extensions.cs b/src/CleanApiStarter.AspNetCore/Extensions.cs
index 89738df..38f29f1 100644
--- a/src/CleanApiStarter.AspNetCore/Extensions.cs
+++ b/src/CleanApiStarter.AspNetCore/Extensions.cs
@@ -1,37 +1,30 @@
namespace CleanApiStarter.AspNetCore;
-
-public static class Extensions
-{
- public static IHostApplicationBuilder AddAspNetCoreDefaults(this IHostApplicationBuilder builder)
- {
- builder.ConfigureOpenTelemetry();
- builder.Logging.AddFilter("Microsoft.AspNetCore.HttpLogging", LogLevel.Information);
- builder.Services.AddHttpLogging(options =>
+public static partial class Extensions
+{
+ public static WebApplicationBuilder AddAspNetCoreDefaults(this WebApplicationBuilder builder)
+ {
+ builder.Host.UseDefaultServiceProvider((_, options) =>
{
- options.LoggingFields =
- HttpLoggingFields.RequestMethod |
- HttpLoggingFields.RequestPath |
- HttpLoggingFields.ResponseStatusCode |
- HttpLoggingFields.Duration;
+ options.ValidateOnBuild = true;
+ options.ValidateScopes = true;
});
- builder.Services.AddServiceDiscovery();
-
- builder.Services.ConfigureHttpClientDefaults(http =>
- {
- http.AddStandardResilienceHandler();
- http.AddServiceDiscovery();
- });
-
- builder.Services.AddHealthChecks()
- .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
-
+ builder.ConfigureOpenTelemetry();
+ builder.AddSecurityDefaults();
+ builder.AddProblemDetailsDefaults();
+ builder.AddApiVersioningDefaults();
+ builder.AddHttpLoggingDefaults();
+ builder.AddServiceDiscoveryDefaults();
+ builder.Services.AddHealthChecks()
+ .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
+
return builder;
}
public static WebApplication UseAspNetCoreDefaults(this WebApplication app)
{
+ app.UseExceptionHandler();
app.UseHttpLogging();
return app;
@@ -39,59 +32,31 @@ public static WebApplication UseAspNetCoreDefaults(this WebApplication app)
public static WebApplication MapDefaultEndpoints(this WebApplication app)
{
+ app.MapGet("/version", () =>
+ {
+ Assembly assembly = Assembly.GetEntryAssembly() ?? typeof(Extensions).Assembly;
+ AssemblyInformationalVersionAttribute? informationalVersion = assembly
+ .GetCustomAttribute();
+
+ return Results.Ok(new
+ {
+ Application = app.Environment.ApplicationName,
+ Version = assembly.GetName().Version?.ToString(),
+ informationalVersion?.InformationalVersion
+ });
+ })
+ .WithName("GetVersion")
+ .WithTags("Version");
+
if (app.Environment.IsDevelopment())
- {
- app.MapHealthChecks("/health");
- app.MapHealthChecks("/alive", new HealthCheckOptions
- {
- Predicate = registration => registration.Tags.Contains("live")
- });
- }
-
- return app;
- }
-
- private static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder)
- {
- builder.Logging.AddOpenTelemetry(logging =>
{
- logging.IncludeFormattedMessage = true;
- logging.IncludeScopes = true;
- logging.ParseStateValues = true;
- });
-
- builder.Services.AddOpenTelemetry()
- .WithMetrics(metrics =>
- {
- metrics.AddAspNetCoreInstrumentation()
- .AddHttpClientInstrumentation()
- .AddNpgsqlInstrumentation()
- .AddRuntimeInstrumentation();
- })
- .WithTracing(tracing =>
- {
- tracing.AddSource(builder.Environment.ApplicationName)
- .AddAspNetCoreInstrumentation()
- .AddHttpClientInstrumentation()
- .AddNpgsql();
- });
-
- builder.AddOpenTelemetryExporters();
-
- return builder;
- }
-
- private static IHostApplicationBuilder AddOpenTelemetryExporters(this IHostApplicationBuilder builder)
- {
- bool useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
-
- if (useOtlpExporter)
- {
- builder.Services.Configure(logging => logging.AddOtlpExporter());
- builder.Services.ConfigureOpenTelemetryMeterProvider(metrics => metrics.AddOtlpExporter());
- builder.Services.ConfigureOpenTelemetryTracerProvider(tracing => tracing.AddOtlpExporter());
- }
-
- return builder;
- }
+ app.MapHealthChecks("/health");
+ app.MapHealthChecks("/alive", new HealthCheckOptions
+ {
+ Predicate = registration => registration.Tags.Contains("live")
+ });
+ }
+
+ return app;
+ }
}
diff --git a/src/CleanApiStarter.AspNetCore/GlobalUsings.cs b/src/CleanApiStarter.AspNetCore/GlobalUsings.cs
index c47d19a..b863a3a 100644
--- a/src/CleanApiStarter.AspNetCore/GlobalUsings.cs
+++ b/src/CleanApiStarter.AspNetCore/GlobalUsings.cs
@@ -1,6 +1,15 @@
+global using System.Reflection;
+
+global using Asp.Versioning;
+global using Asp.Versioning.Builder;
+
global using Microsoft.AspNetCore.Builder;
+global using Microsoft.AspNetCore.Diagnostics;
global using Microsoft.AspNetCore.Diagnostics.HealthChecks;
+global using Microsoft.AspNetCore.Http;
global using Microsoft.AspNetCore.HttpLogging;
+global using Microsoft.AspNetCore.Mvc;
+global using Microsoft.AspNetCore.Routing;
global using Microsoft.Extensions.DependencyInjection;
global using Microsoft.Extensions.Diagnostics.HealthChecks;
global using Microsoft.Extensions.Hosting;
diff --git a/src/CleanApiStarter.AspNetCore/IEndpointGroup.cs b/src/CleanApiStarter.AspNetCore/IEndpointGroup.cs
new file mode 100644
index 0000000..66a2135
--- /dev/null
+++ b/src/CleanApiStarter.AspNetCore/IEndpointGroup.cs
@@ -0,0 +1,10 @@
+namespace CleanApiStarter.AspNetCore;
+
+public interface IEndpointGroup
+{
+ static virtual int MajorVersion => 1;
+
+ static virtual string? RoutePrefix => null;
+
+ static abstract void Map(RouteGroupBuilder groupBuilder);
+}
diff --git a/src/CleanApiStarter.AspNetCore/OpenTelemetryDefaults.cs b/src/CleanApiStarter.AspNetCore/OpenTelemetryDefaults.cs
new file mode 100644
index 0000000..70733a2
--- /dev/null
+++ b/src/CleanApiStarter.AspNetCore/OpenTelemetryDefaults.cs
@@ -0,0 +1,41 @@
+namespace CleanApiStarter.AspNetCore;
+
+public static partial class Extensions
+{
+ private static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder)
+ {
+ builder.Logging.AddOpenTelemetry(logging =>
+ {
+ logging.IncludeFormattedMessage = true;
+ logging.IncludeScopes = true;
+ logging.ParseStateValues = true;
+ });
+
+ builder.Services.AddOpenTelemetry()
+ .WithMetrics(metrics =>
+ {
+ metrics.AddAspNetCoreInstrumentation()
+ .AddHttpClientInstrumentation()
+ .AddNpgsqlInstrumentation()
+ .AddRuntimeInstrumentation();
+ })
+ .WithTracing(tracing =>
+ {
+ tracing.AddSource(builder.Environment.ApplicationName)
+ .AddAspNetCoreInstrumentation()
+ .AddHttpClientInstrumentation()
+ .AddNpgsql();
+ });
+
+ bool useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
+
+ if (useOtlpExporter)
+ {
+ builder.Services.Configure(logging => logging.AddOtlpExporter());
+ builder.Services.ConfigureOpenTelemetryMeterProvider(metrics => metrics.AddOtlpExporter());
+ builder.Services.ConfigureOpenTelemetryTracerProvider(tracing => tracing.AddOtlpExporter());
+ }
+
+ return builder;
+ }
+}
diff --git a/src/CleanApiStarter.AspNetCore/ProblemDetailsExceptionHandler.cs b/src/CleanApiStarter.AspNetCore/ProblemDetailsExceptionHandler.cs
new file mode 100644
index 0000000..0c2320c
--- /dev/null
+++ b/src/CleanApiStarter.AspNetCore/ProblemDetailsExceptionHandler.cs
@@ -0,0 +1,54 @@
+namespace CleanApiStarter.AspNetCore;
+
+public sealed class ProblemDetailsExceptionHandler : IExceptionHandler
+{
+ public async ValueTask TryHandleAsync(
+ HttpContext httpContext,
+ Exception exception,
+ CancellationToken cancellationToken)
+ {
+ (int statusCode, ProblemDetails problemDetails) = exception switch
+ {
+ BadHttpRequestException badRequestException => (
+ StatusCodes.Status400BadRequest,
+ new ProblemDetails
+ {
+ Status = StatusCodes.Status400BadRequest,
+ Title = "Bad Request",
+ Type = "https://tools.ietf.org/html/rfc9110#section-15.5.1",
+ Detail = badRequestException.Message
+ }),
+ ArgumentException argumentException => (
+ StatusCodes.Status400BadRequest,
+ new ProblemDetails
+ {
+ Status = StatusCodes.Status400BadRequest,
+ Title = "Bad Request",
+ Type = "https://tools.ietf.org/html/rfc9110#section-15.5.1",
+ Detail = argumentException.Message
+ }),
+ UnauthorizedAccessException => (
+ StatusCodes.Status401Unauthorized,
+ new ProblemDetails
+ {
+ Status = StatusCodes.Status401Unauthorized,
+ Title = "Unauthorized",
+ Type = "https://tools.ietf.org/html/rfc9110#section-15.5.2"
+ }),
+ _ => (
+ StatusCodes.Status500InternalServerError,
+ new ProblemDetails
+ {
+ Status = StatusCodes.Status500InternalServerError,
+ Title = "Internal Server Error",
+ Type = "https://tools.ietf.org/html/rfc9110#section-15.6.1",
+ Detail = "An unexpected error occurred."
+ })
+ };
+
+ httpContext.Response.StatusCode = statusCode;
+ await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
+
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/src/CleanApiStarter.AspNetCore/WebApplicationExtensions.cs b/src/CleanApiStarter.AspNetCore/WebApplicationExtensions.cs
new file mode 100644
index 0000000..59f4337
--- /dev/null
+++ b/src/CleanApiStarter.AspNetCore/WebApplicationExtensions.cs
@@ -0,0 +1,39 @@
+namespace CleanApiStarter.AspNetCore;
+
+public static class WebApplicationExtensions
+{
+ public static WebApplication MapEndpoints(this WebApplication app, Assembly assembly)
+ {
+ IEnumerable endpointGroupTypes = assembly.GetExportedTypes()
+ .Where(type => type is { IsAbstract: false, IsInterface: false }
+ && type.IsAssignableTo(typeof(IEndpointGroup)));
+
+ foreach (Type type in endpointGroupTypes)
+ {
+ ApiVersion apiVersion = GetApiVersion(type);
+ string groupName = type.Name;
+ string routePrefix = type.GetProperty(nameof(IEndpointGroup.RoutePrefix))?.GetValue(null) as string
+ ?? $"/api/{groupName}";
+ ApiVersionSet versionSet = app.NewApiVersionSet()
+ .HasApiVersion(apiVersion)
+ .ReportApiVersions()
+ .Build();
+
+ RouteGroupBuilder group = app.MapGroup(routePrefix)
+ .WithTags(groupName)
+ .WithApiVersionSet(versionSet)
+ .MapToApiVersion(apiVersion);
+
+ type.GetMethod(nameof(IEndpointGroup.Map))!.Invoke(null, [group]);
+ }
+
+ return app;
+ }
+
+ private static ApiVersion GetApiVersion(Type endpointGroupType)
+ {
+ int majorVersion = endpointGroupType.GetProperty(nameof(IEndpointGroup.MajorVersion))?.GetValue(null) as int? ?? 1;
+
+ return new ApiVersion(majorVersion);
+ }
+}
diff --git a/src/CleanApiStarter.Configuration/AppSettings.cs b/src/CleanApiStarter.Configuration/AppSettings.cs
new file mode 100644
index 0000000..fd74519
--- /dev/null
+++ b/src/CleanApiStarter.Configuration/AppSettings.cs
@@ -0,0 +1,8 @@
+namespace CleanApiStarter.Configuration;
+
+public sealed class AppSettings
+{
+ [Required]
+ [ValidateObjectMembers]
+ public ConnectionStringSettings ConnectionStrings { get; set; } = new();
+}
diff --git a/src/CleanApiStarter.Configuration/CleanApiStarter.Configuration.csproj b/src/CleanApiStarter.Configuration/CleanApiStarter.Configuration.csproj
new file mode 100644
index 0000000..002e9d2
--- /dev/null
+++ b/src/CleanApiStarter.Configuration/CleanApiStarter.Configuration.csproj
@@ -0,0 +1,15 @@
+
+
+
+ CleanApiStarter.Configuration
+ CleanApiStarter.Configuration
+
+
+
+
+
+
+
+
+
+
diff --git a/src/CleanApiStarter.Configuration/ConnectionStringSettings.cs b/src/CleanApiStarter.Configuration/ConnectionStringSettings.cs
new file mode 100644
index 0000000..1299016
--- /dev/null
+++ b/src/CleanApiStarter.Configuration/ConnectionStringSettings.cs
@@ -0,0 +1,7 @@
+namespace CleanApiStarter.Configuration;
+
+public sealed class ConnectionStringSettings
+{
+ [Required]
+ public string Postgres { get; set; } = string.Empty;
+}
diff --git a/src/CleanApiStarter.Configuration/GlobalUsings.cs b/src/CleanApiStarter.Configuration/GlobalUsings.cs
new file mode 100644
index 0000000..a2250c9
--- /dev/null
+++ b/src/CleanApiStarter.Configuration/GlobalUsings.cs
@@ -0,0 +1,5 @@
+global using System.ComponentModel.DataAnnotations;
+
+global using Microsoft.Extensions.Configuration;
+global using Microsoft.Extensions.DependencyInjection;
+global using Microsoft.Extensions.Options;
diff --git a/src/CleanApiStarter.Configuration/OptionsRegistrationExtensions.cs b/src/CleanApiStarter.Configuration/OptionsRegistrationExtensions.cs
new file mode 100644
index 0000000..6b767c7
--- /dev/null
+++ b/src/CleanApiStarter.Configuration/OptionsRegistrationExtensions.cs
@@ -0,0 +1,19 @@
+namespace CleanApiStarter.Configuration;
+
+public static class OptionsRegistrationExtensions
+{
+ public static IServiceCollection AddAppSettings(
+ this IServiceCollection services,
+ IConfiguration configuration)
+ {
+ services.AddOptions()
+ .Bind(configuration)
+ .ValidateDataAnnotations()
+ .ValidateOnStart();
+
+ services.AddSingleton(serviceProvider =>
+ serviceProvider.GetRequiredService>().Value);
+
+ return services;
+ }
+}
diff --git a/src/CleanApiStarter.Infrastructure/CleanApiStarter.Infrastructure.csproj b/src/CleanApiStarter.Infrastructure/CleanApiStarter.Infrastructure.csproj
index 772f2b1..a814aa1 100644
--- a/src/CleanApiStarter.Infrastructure/CleanApiStarter.Infrastructure.csproj
+++ b/src/CleanApiStarter.Infrastructure/CleanApiStarter.Infrastructure.csproj
@@ -7,6 +7,7 @@
+
diff --git a/src/CleanApiStarter.Infrastructure/Database/DatabaseConnectionFactory.cs b/src/CleanApiStarter.Infrastructure/Database/DatabaseConnectionFactory.cs
index c406b04..b82d596 100644
--- a/src/CleanApiStarter.Infrastructure/Database/DatabaseConnectionFactory.cs
+++ b/src/CleanApiStarter.Infrastructure/Database/DatabaseConnectionFactory.cs
@@ -5,10 +5,10 @@ public interface IDatabaseConnectionFactory
IDbConnection CreateConnection();
}
-public class PostgresConnectionFactory(string connectionString) : IDatabaseConnectionFactory
-{
- public IDbConnection CreateConnection()
- {
- return new NpgsqlConnection(connectionString);
- }
-}
\ No newline at end of file
+public class PostgresConnectionFactory(AppSettings appSettings) : IDatabaseConnectionFactory
+{
+ public IDbConnection CreateConnection()
+ {
+ return new NpgsqlConnection(appSettings.ConnectionStrings.Postgres);
+ }
+}
diff --git a/src/CleanApiStarter.Infrastructure/DependencyInjection.cs b/src/CleanApiStarter.Infrastructure/DependencyInjection.cs
index a1b6aa1..b2a5e19 100644
--- a/src/CleanApiStarter.Infrastructure/DependencyInjection.cs
+++ b/src/CleanApiStarter.Infrastructure/DependencyInjection.cs
@@ -1,24 +1,12 @@
namespace CleanApiStarter.Infrastructure;
-public static class DependencyInjection
-{
- public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
- {
- // Register database connection factory
- string? connectionString =
- configuration.GetConnectionString("postgres") ??
- configuration.GetConnectionString("DefaultConnection");
+public static class DependencyInjection
+{
+ public static IServiceCollection AddInfrastructure(this IServiceCollection services)
+ {
+ services.AddSingleton();
+ services.AddScoped();
- if (string.IsNullOrWhiteSpace(connectionString))
- {
- throw new InvalidOperationException("Database connection string 'postgres' or 'DefaultConnection' is missing.");
- }
-
- services.AddSingleton(_ => new PostgresConnectionFactory(connectionString!));
-
- // Register repositories
- services.AddScoped();
-
- return services;
+ return services;
}
}
diff --git a/src/CleanApiStarter.Infrastructure/GlobalUsings.cs b/src/CleanApiStarter.Infrastructure/GlobalUsings.cs
index 3b36d17..0106a08 100644
--- a/src/CleanApiStarter.Infrastructure/GlobalUsings.cs
+++ b/src/CleanApiStarter.Infrastructure/GlobalUsings.cs
@@ -1,14 +1,14 @@
-global using System.Data;
-global using System.Text.Json;
-
-global using CleanApiStarter.Application.Interfaces;
-global using CleanApiStarter.Domain.Entities;
-global using CleanApiStarter.Infrastructure.Database;
-global using CleanApiStarter.Infrastructure.Repositories;
-
-global using Dapper;
-
-global using Microsoft.Extensions.Configuration;
-global using Microsoft.Extensions.DependencyInjection;
-
-global using Npgsql;
\ No newline at end of file
+global using System.Data;
+global using System.Text.Json;
+
+global using CleanApiStarter.Application.Interfaces;
+global using CleanApiStarter.Configuration;
+global using CleanApiStarter.Domain.Entities;
+global using CleanApiStarter.Infrastructure.Database;
+global using CleanApiStarter.Infrastructure.Repositories;
+
+global using Dapper;
+
+global using Microsoft.Extensions.DependencyInjection;
+
+global using Npgsql;
diff --git a/src/CleanApiStarter.Infrastructure/Repositories/WordRepository.cs b/src/CleanApiStarter.Infrastructure/Repositories/WordRepository.cs
index d255b5e..ce6b6cf 100644
--- a/src/CleanApiStarter.Infrastructure/Repositories/WordRepository.cs
+++ b/src/CleanApiStarter.Infrastructure/Repositories/WordRepository.cs
@@ -2,7 +2,7 @@ namespace CleanApiStarter.Infrastructure.Repositories;
public class WordRepository(IDatabaseConnectionFactory connectionFactory) : IWordRepository
{
- public async Task AddWordAsync(Word word, CancellationToken cancellationToken)
+ public async Task AddWordAsync(Word word, CancellationToken cancellationToken)
{
const string sql =
"""
@@ -28,7 +28,7 @@ RETURNING id
return id;
}
- public async Task GetWordByIdAsync(Guid id, CancellationToken cancellationToken)
+ public async Task GetWordByIdAsync(Guid id, CancellationToken cancellationToken)
{
const string sql =
"""
@@ -43,7 +43,7 @@ FROM words
return word?.ToDomain();
}
- public async Task> GetAllWordsAsync(CancellationToken cancellationToken)
+ public async Task> GetAllWordsAsync(CancellationToken cancellationToken)
{
const string sql =
"""
@@ -57,7 +57,7 @@ FROM words
return words.Select(word => word.ToDomain());
}
- public async Task UpdateWordAsync(Word word, CancellationToken cancellationToken)
+ public async Task UpdateWordAsync(Word word, CancellationToken cancellationToken)
{
const string sql =
"""
@@ -85,7 +85,7 @@ UPDATE words
return rowsAffected > 0;
}
- public async Task DeleteWordAsync(Guid id, CancellationToken cancellationToken)
+ public async Task DeleteWordAsync(Guid id, CancellationToken cancellationToken)
{
const string sql = "DELETE FROM words WHERE id = @Id";
@@ -118,4 +118,4 @@ public Word ToDomain()
};
}
}
-}
+}
\ No newline at end of file
From 937cacf052d0d2e2676634ac35e03bdcac01bb70 Mon Sep 17 00:00:00 2001
From: cbjpdev <42979311+cstacklab@users.noreply.github.com>
Date: Mon, 27 Apr 2026 07:03:22 +0800
Subject: [PATCH 05/33] implement authentication with Google ID tokens and JWT,
refactor data access to use EF Core
---
AGENTS.md | 24 +-
CleanApiStarter.slnx | 1 +
Directory.Packages.props | 6 +-
README.md | 28 ++-
.../V002__create_identity_tables.sql | 91 ++++++++
src/CleanApiStarter.Api/Api.http | 25 ++-
.../CleanApiStarter.Api.csproj | 1 +
.../Endpoints/GoogleLoginPage.cs | 65 ++++++
src/CleanApiStarter.Api/Endpoints/V1/Auth.cs | 39 ++++
src/CleanApiStarter.Api/Endpoints/V1/Words.cs | 4 +-
src/CleanApiStarter.Api/Endpoints/V2/Words.cs | 4 +-
src/CleanApiStarter.Api/GlobalUsings.cs | 11 +-
src/CleanApiStarter.Api/Program.cs | 16 +-
.../Services/CurrentUser.cs | 9 +
.../appsettings.Development.json | 11 +
.../Common/Interfaces/IUser.cs | 7 +
.../Features/Auth/AuthTokenDto.cs | 8 +
.../Features/Auth/CurrentUserDto.cs | 12 +
.../Features/Auth/GoogleSignInDto.cs | 6 +
.../Features/Auth/IAuthService.cs | 8 +
.../Words}/IWordRepository.cs | 18 +-
.../Words}/IWordService.cs | 18 +-
.../{Models => Features/Words}/WordDto.cs | 34 +--
.../Words}/WordService.cs | 208 +++++++++---------
.../GlobalUsings.cs | 6 +-
.../AspNetCoreDefaultServices.cs | 33 ++-
.../CleanApiStarter.AspNetCore.csproj | 5 +
src/CleanApiStarter.AspNetCore/Extensions.cs | 5 +-
.../GlobalUsings.cs | 42 ++--
.../IEndpointGroup.cs | 2 +-
.../OpenTelemetryDefaults.cs | 2 +-
.../ProblemDetailsExceptionHandler.cs | 20 +-
.../UserIdHttpLoggingInterceptor.cs | 28 +++
.../WebApplicationExtensions.cs | 2 +-
.../AppSettings.cs | 6 +-
.../AuthenticationSettings.cs | 12 +
.../ConnectionStringSettings.cs | 2 +-
.../GlobalUsings.cs | 2 +-
.../GoogleAuthenticationSettings.cs | 7 +
.../JwtAuthenticationSettings.cs | 17 ++
.../OptionsRegistrationExtensions.cs | 2 +-
.../CleanApiStarter.Infrastructure.csproj | 6 +-
.../Database/DatabaseConnectionFactory.cs | 14 --
.../DependencyInjection.cs | 28 ++-
.../GlobalUsings.cs | 19 +-
.../Identity/ApplicationUser.cs | 3 +
.../Identity/GoogleAuthService.cs | 151 +++++++++++++
.../Persistence/ApplicationDbContext.cs | 14 ++
.../Configuration/WordConfiguration.cs | 40 ++++
.../Repositories/WordRepository.cs | 109 ++-------
.../Controllers/WordsControllerTests.cs | 131 -----------
.../Repositories/WordRepositoryTests.cs | 61 -----
.../Services/WordServiceTests.cs | 139 ------------
53 files changed, 917 insertions(+), 645 deletions(-)
create mode 100644 database/migrations/V002__create_identity_tables.sql
create mode 100644 src/CleanApiStarter.Api/Endpoints/GoogleLoginPage.cs
create mode 100644 src/CleanApiStarter.Api/Endpoints/V1/Auth.cs
create mode 100644 src/CleanApiStarter.Api/Services/CurrentUser.cs
create mode 100644 src/CleanApiStarter.Application/Common/Interfaces/IUser.cs
create mode 100644 src/CleanApiStarter.Application/Features/Auth/AuthTokenDto.cs
create mode 100644 src/CleanApiStarter.Application/Features/Auth/CurrentUserDto.cs
create mode 100644 src/CleanApiStarter.Application/Features/Auth/GoogleSignInDto.cs
create mode 100644 src/CleanApiStarter.Application/Features/Auth/IAuthService.cs
rename src/CleanApiStarter.Application/{Interfaces => Features/Words}/IWordRepository.cs (88%)
rename src/CleanApiStarter.Application/{Interfaces => Features/Words}/IWordService.cs (88%)
rename src/CleanApiStarter.Application/{Models => Features/Words}/WordDto.cs (89%)
rename src/CleanApiStarter.Application/{Services => Features/Words}/WordService.cs (95%)
create mode 100644 src/CleanApiStarter.AspNetCore/UserIdHttpLoggingInterceptor.cs
create mode 100644 src/CleanApiStarter.Configuration/AuthenticationSettings.cs
create mode 100644 src/CleanApiStarter.Configuration/GoogleAuthenticationSettings.cs
create mode 100644 src/CleanApiStarter.Configuration/JwtAuthenticationSettings.cs
delete mode 100644 src/CleanApiStarter.Infrastructure/Database/DatabaseConnectionFactory.cs
create mode 100644 src/CleanApiStarter.Infrastructure/Identity/ApplicationUser.cs
create mode 100644 src/CleanApiStarter.Infrastructure/Identity/GoogleAuthService.cs
create mode 100644 src/CleanApiStarter.Infrastructure/Persistence/ApplicationDbContext.cs
create mode 100644 src/CleanApiStarter.Infrastructure/Persistence/Configuration/WordConfiguration.cs
delete mode 100644 tests/CleanApiStarter.UnitTests/Controllers/WordsControllerTests.cs
delete mode 100644 tests/CleanApiStarter.UnitTests/Repositories/WordRepositoryTests.cs
delete mode 100644 tests/CleanApiStarter.UnitTests/Services/WordServiceTests.cs
diff --git a/AGENTS.md b/AGENTS.md
index fe52dad..edbeebc 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -40,12 +40,17 @@ This repository is a Clean Architecture API starter template named `CleanApiStar
- `Api` composes `Application`, `Infrastructure`, `Configuration`, and `AspNetCore`.
- Keep repository interfaces in `Application`, not `Domain`.
- Keep database implementation details in `Infrastructure`.
-- Do not move `IDatabaseConnectionFactory` into `Application` or `Domain`.
- Keep domain models persistence-agnostic. For example, `Word.Synonyms` stays `List`, even though Postgres stores it as `jsonb`.
-- Private database projection types such as `WordRow` may live inside their repository when they are only used there.
+- Use EF Core through `ApplicationDbContext` for application persistence. Do not reintroduce Dapper for the default template data access path.
+- Keep `ApplicationDbContext` in `CleanApiStarter.Infrastructure/Persistence`, not in `Identity`. The context owns both application entities and Identity storage, so it is a persistence concern.
+- Keep EF Core fluent API entity maps in `CleanApiStarter.Infrastructure/Persistence/Configuration` using `IEntityTypeConfiguration`. Do not put entity mapping logic directly inside `ApplicationDbContext` unless there is a very small one-off reason.
+- Keep Identity-specific classes, such as `ApplicationUser` and auth services, under `CleanApiStarter.Infrastructure/Identity`.
## API and Application Conventions
+- Organize the `Application` project by feature. Put feature-specific contracts, DTOs, and application services under folders such as `Application/Features/Words` or `Application/Features/Auth`.
+- Do not create generic `Application/Services` or `Application/Models` folders for feature-specific code.
+- Keep cross-cutting application abstractions under `Application/Common`, for example `Application/Common/Interfaces`.
- Use Scalar, not Swagger/Swashbuckle.
- Use `Microsoft.AspNetCore.OpenApi` with:
- `builder.Services.AddOpenApi();`
@@ -67,6 +72,7 @@ This repository is a Clean Architecture API starter template named `CleanApiStar
- Database schema scripts live in `database/migrations`, for example:
- `database/migrations/V001__create_words_table.sql`
- Do not add API startup database initialization such as `DbInitializer` or DbUp calls. Aspire/Docker init scripts own local schema creation.
+- EF Core is used for application data access and Identity storage, but schema creation still belongs to the SQL scripts in `database/migrations`.
- Docker Postgres init scripts run only on first volume creation. If scripts need to replay, delete the old volume.
- This repo uses `postgres:latest`. Because Postgres 18+ expects the data volume mounted at `/var/lib/postgresql`, do not mount the volume at `/var/lib/postgresql/data`.
- In Aspire, use a server resource name that does not conflict with the database resource name, for example:
@@ -103,11 +109,23 @@ This repository is a Clean Architecture API starter template named `CleanApiStar
- Keep `Program.cs` small by placing route groups in endpoint group classes under version folders such as `Api/Endpoints/V1/Words.cs`.
- Endpoint group classes should implement `IEndpointGroup` from `CleanApiStarter.AspNetCore` and be mapped through `app.MapEndpoints(Assembly.GetExecutingAssembly());`.
- Use built-in Minimal API mapping methods with explicit `.WithName(...)`; do not add custom `MapGet`/`MapPost` overloads that shadow framework methods.
-- API versions are selected with the required `X-Api-Version` request header.
+- API versions are selected with the optional `X-Api-Version` request header. Missing versions default to v1.
- Endpoint groups should declare `MajorVersion` to match their folder, for example `V1` uses `1` and `V2` uses `2`.
- Endpoint names must be globally unique across versions. Prefer names suffixed with the version, such as `GetAllWordsV1` and `GetAllWordsV2`.
- Do not reintroduce MVC controllers unless the template intentionally changes direction.
+## Authentication
+
+- Authentication is API-first:
+ - clients obtain a Google ID token
+ - `POST /api/auth/google` validates it
+ - the API issues its own JWT
+- Keep JWT bearer authentication setup in `CleanApiStarter.AspNetCore`.
+- Keep local user/role storage in ASP.NET Core Identity under `CleanApiStarter.Infrastructure`.
+- Do not use cookies as the default API auth mechanism.
+- Keep the development Google login helper page unversioned so it can be opened directly in a browser.
+- Protected API calls should send `Authorization: Bearer `. Send `X-Api-Version` only when selecting a non-default API version.
+
## Packages
- Manage versions centrally in `Directory.Packages.props`.
diff --git a/CleanApiStarter.slnx b/CleanApiStarter.slnx
index 60e10b1..7fa4829 100644
--- a/CleanApiStarter.slnx
+++ b/CleanApiStarter.slnx
@@ -11,6 +11,7 @@
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 2db7419..601f3d5 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -14,8 +14,10 @@
-
+
+
+
@@ -30,6 +32,7 @@
+
@@ -39,6 +42,7 @@
+
diff --git a/README.md b/README.md
index 5214914..5a2c448 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,31 @@ A Clean Architecture API starter template for .NET, Aspire, PostgreSQL, and Open
The API uses Minimal APIs with endpoint group classes. `CleanApiStarter.AspNetCore` discovers endpoint groups from the API assembly so `Program.cs` stays small while routes remain easy to find.
-API versions are selected with the required `X-Api-Version` request header. Endpoint groups live under version folders such as `Endpoints/V1` and `Endpoints/V2`.
+API versions are selected with the optional `X-Api-Version` request header. Requests without a version use v1 by default. Endpoint groups live under version folders such as `Endpoints/V1` and `Endpoints/V2`.
+
+## Authentication
+
+The API validates Google ID tokens and issues its own JWT access token. Google proves the user identity; ASP.NET Core Identity stores local users, roles, and external logins.
+
+Set the Google client id with user-secrets:
+
+```bash
+dotnet user-secrets set "Authentication:Google:ClientId" "" --project src/CleanApiStarter.Api/CleanApiStarter.Api.csproj
+```
+
+For local testing, open the development-only helper page:
+
+```text
+https://localhost:7285/auth/google-login
+```
+
+After signing in with Google, the page calls `POST /api/auth/google` and shows the API JWT. Use that JWT with protected endpoints:
+
+```http
+Authorization: Bearer
+```
+
+New Google users are created as local Identity users and assigned the `User` role by default. The database init scripts create the `User` and `Admin` roles.
## Learn OpenTelemetry with .NET Aspire
@@ -34,7 +58,7 @@ Database scripts live in `database`. Aspire and Docker Compose copy `database/mi
After the dashboard is running, send requests to the API through Scalar or another HTTP client. In the Aspire dashboard:
- Logs show structured application and framework log entries. The word use cases emit fields such as `WordId`, `SynonymCount`, `WordCount`, and `UpdateSucceeded`.
-- Request logs show method, path, response status code, and duration without logging request or response bodies. `CleanApiStarter.AspNetCore` keeps the `Microsoft.AspNetCore.HttpLogging` category at `Information` even when broader ASP.NET Core logs are filtered to `Warning`.
+- Request logs show method, path, response status code, duration, and `UserId` without logging request or response bodies. `CleanApiStarter.AspNetCore` keeps the `Microsoft.AspNetCore.HttpLogging` category at `Information` even when broader ASP.NET Core logs are filtered to `Warning`.
- Traces show incoming HTTP requests and PostgreSQL commands from Npgsql.
- Metrics show ASP.NET Core, HTTP client, and .NET runtime measurements.
- `/version` exposes the running application version.
diff --git a/database/migrations/V002__create_identity_tables.sql b/database/migrations/V002__create_identity_tables.sql
new file mode 100644
index 0000000..43c1f17
--- /dev/null
+++ b/database/migrations/V002__create_identity_tables.sql
@@ -0,0 +1,91 @@
+CREATE TABLE IF NOT EXISTS "AspNetRoles" (
+ "Id" TEXT PRIMARY KEY,
+ "Name" VARCHAR(256) NULL,
+ "NormalizedName" VARCHAR(256) NULL,
+ "ConcurrencyStamp" TEXT NULL
+);
+
+CREATE TABLE IF NOT EXISTS "AspNetUsers" (
+ "Id" TEXT PRIMARY KEY,
+ "UserName" VARCHAR(256) NULL,
+ "NormalizedUserName" VARCHAR(256) NULL,
+ "Email" VARCHAR(256) NULL,
+ "NormalizedEmail" VARCHAR(256) NULL,
+ "EmailConfirmed" BOOLEAN NOT NULL,
+ "PasswordHash" TEXT NULL,
+ "SecurityStamp" TEXT NULL,
+ "ConcurrencyStamp" TEXT NULL,
+ "PhoneNumber" TEXT NULL,
+ "PhoneNumberConfirmed" BOOLEAN NOT NULL,
+ "TwoFactorEnabled" BOOLEAN NOT NULL,
+ "LockoutEnd" TIMESTAMP WITH TIME ZONE NULL,
+ "LockoutEnabled" BOOLEAN NOT NULL,
+ "AccessFailedCount" INTEGER NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS "AspNetRoleClaims" (
+ "Id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ "RoleId" TEXT NOT NULL,
+ "ClaimType" TEXT NULL,
+ "ClaimValue" TEXT NULL,
+ CONSTRAINT "FK_AspNetRoleClaims_AspNetRoles_RoleId"
+ FOREIGN KEY ("RoleId") REFERENCES "AspNetRoles" ("Id") ON DELETE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS "AspNetUserClaims" (
+ "Id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ "UserId" TEXT NOT NULL,
+ "ClaimType" TEXT NULL,
+ "ClaimValue" TEXT NULL,
+ CONSTRAINT "FK_AspNetUserClaims_AspNetUsers_UserId"
+ FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS "AspNetUserLogins" (
+ "LoginProvider" TEXT NOT NULL,
+ "ProviderKey" TEXT NOT NULL,
+ "ProviderDisplayName" TEXT NULL,
+ "UserId" TEXT NOT NULL,
+ CONSTRAINT "PK_AspNetUserLogins"
+ PRIMARY KEY ("LoginProvider", "ProviderKey"),
+ CONSTRAINT "FK_AspNetUserLogins_AspNetUsers_UserId"
+ FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS "AspNetUserRoles" (
+ "UserId" TEXT NOT NULL,
+ "RoleId" TEXT NOT NULL,
+ CONSTRAINT "PK_AspNetUserRoles"
+ PRIMARY KEY ("UserId", "RoleId"),
+ CONSTRAINT "FK_AspNetUserRoles_AspNetRoles_RoleId"
+ FOREIGN KEY ("RoleId") REFERENCES "AspNetRoles" ("Id") ON DELETE CASCADE,
+ CONSTRAINT "FK_AspNetUserRoles_AspNetUsers_UserId"
+ FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS "AspNetUserTokens" (
+ "UserId" TEXT NOT NULL,
+ "LoginProvider" TEXT NOT NULL,
+ "Name" TEXT NOT NULL,
+ "Value" TEXT NULL,
+ CONSTRAINT "PK_AspNetUserTokens"
+ PRIMARY KEY ("UserId", "LoginProvider", "Name"),
+ CONSTRAINT "FK_AspNetUserTokens_AspNetUsers_UserId"
+ FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE
+);
+
+CREATE INDEX IF NOT EXISTS "EmailIndex" ON "AspNetUsers" ("NormalizedEmail");
+CREATE UNIQUE INDEX IF NOT EXISTS "RoleNameIndex" ON "AspNetRoles" ("NormalizedName");
+CREATE UNIQUE INDEX IF NOT EXISTS "UserNameIndex" ON "AspNetUsers" ("NormalizedUserName");
+CREATE INDEX IF NOT EXISTS "IX_AspNetRoleClaims_RoleId" ON "AspNetRoleClaims" ("RoleId");
+CREATE INDEX IF NOT EXISTS "IX_AspNetUserClaims_UserId" ON "AspNetUserClaims" ("UserId");
+CREATE INDEX IF NOT EXISTS "IX_AspNetUserLogins_UserId" ON "AspNetUserLogins" ("UserId");
+CREATE INDEX IF NOT EXISTS "IX_AspNetUserRoles_RoleId" ON "AspNetUserRoles" ("RoleId");
+
+INSERT INTO "AspNetRoles" ("Id", "Name", "NormalizedName", "ConcurrencyStamp")
+VALUES ('user', 'User', 'USER', gen_random_uuid()::text)
+ON CONFLICT ("Id") DO NOTHING;
+
+INSERT INTO "AspNetRoles" ("Id", "Name", "NormalizedName", "ConcurrencyStamp")
+VALUES ('admin', 'Admin', 'ADMIN', gen_random_uuid()::text)
+ON CONFLICT ("Id") DO NOTHING;
diff --git a/src/CleanApiStarter.Api/Api.http b/src/CleanApiStarter.Api/Api.http
index 2bef6bf..7deb1e4 100644
--- a/src/CleanApiStarter.Api/Api.http
+++ b/src/CleanApiStarter.Api/Api.http
@@ -1,14 +1,29 @@
@CleanApiStarter_Api_HostAddress = https://localhost:7285
+@AccessToken = paste-api-jwt-here
GET {{CleanApiStarter_Api_HostAddress}}/api/words
Accept: application/json
-X-Api-Version: 1.0
+
+###
+
+POST {{CleanApiStarter_Api_HostAddress}}/api/auth/google
+Content-Type: application/json
+
+{
+ "idToken": "paste-google-id-token-here"
+}
+
+###
+
+GET {{CleanApiStarter_Api_HostAddress}}/api/auth/me
+Accept: application/json
+Authorization: Bearer {{AccessToken}}
###
POST {{CleanApiStarter_Api_HostAddress}}/api/words
Content-Type: application/json
-X-Api-Version: 1.0
+Authorization: Bearer {{AccessToken}}
{
"text": "observable",
@@ -22,5 +37,11 @@ X-Api-Version: 1.0
GET {{CleanApiStarter_Api_HostAddress}}/api/words
Accept: application/json
X-Api-Version: 2.0
+Authorization: Bearer {{AccessToken}}
+
+###
+
+DELETE {{CleanApiStarter_Api_HostAddress}}/api/words/00000000-0000-0000-0000-000000000000
+Authorization: Bearer {{AccessToken}}
###
diff --git a/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj b/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj
index 6014433..47c56e1 100644
--- a/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj
+++ b/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj
@@ -3,6 +3,7 @@
CleanApiStarter.Api
CleanApiStarter.Api
+ clean-api-starter
diff --git a/src/CleanApiStarter.Api/Endpoints/GoogleLoginPage.cs b/src/CleanApiStarter.Api/Endpoints/GoogleLoginPage.cs
new file mode 100644
index 0000000..e57f2ec
--- /dev/null
+++ b/src/CleanApiStarter.Api/Endpoints/GoogleLoginPage.cs
@@ -0,0 +1,65 @@
+namespace CleanApiStarter.Api.Endpoints;
+
+public static class GoogleLoginPage
+{
+ public static WebApplication MapGoogleLoginPage(this WebApplication app)
+ {
+ if (!app.Environment.IsDevelopment())
+ {
+ return app;
+ }
+
+ app.MapGet("/auth/google-login", (AppSettings appSettings) =>
+ {
+ string clientId = HtmlEncoder.Default.Encode(appSettings.Authentication.Google.ClientId);
+
+ string html =
+ $$"""
+
+
+
+
+
+ Google Login
+
+
+
+
+ Google Login
+
+
+
+ API JWT
+ Sign in with Google to generate a token.
+
+
+
+
+ """;
+
+ return Results.Content(html, "text/html");
+ })
+ .AllowAnonymous()
+ .WithName("GoogleLoginPage");
+
+ return app;
+ }
+}
diff --git a/src/CleanApiStarter.Api/Endpoints/V1/Auth.cs b/src/CleanApiStarter.Api/Endpoints/V1/Auth.cs
new file mode 100644
index 0000000..13fa398
--- /dev/null
+++ b/src/CleanApiStarter.Api/Endpoints/V1/Auth.cs
@@ -0,0 +1,39 @@
+namespace CleanApiStarter.Api.Endpoints.V1;
+
+public sealed class Auth : IEndpointGroup
+{
+ public static int MajorVersion => 1;
+
+ public static string RoutePrefix => "/api/auth";
+
+ public static void Map(RouteGroupBuilder groupBuilder)
+ {
+ groupBuilder.MapPost("/google", SignInWithGoogle)
+ .AllowAnonymous()
+ .WithName("SignInWithGoogleV1");
+
+ groupBuilder.MapGet("/me", GetCurrentUser)
+ .RequireAuthorization()
+ .WithName("GetCurrentUserV1");
+ }
+
+ private static async Task SignInWithGoogle(
+ GoogleSignInDto signInDto,
+ IAuthService authService,
+ CancellationToken cancellationToken)
+ {
+ AuthTokenDto token = await authService.SignInWithGoogleAsync(signInDto, cancellationToken);
+
+ return Results.Ok(token);
+ }
+
+ private static async Task GetCurrentUser(
+ ClaimsPrincipal principal,
+ IAuthService authService,
+ CancellationToken cancellationToken)
+ {
+ CurrentUserDto currentUser = await authService.GetCurrentUserAsync(principal, cancellationToken);
+
+ return Results.Ok(currentUser);
+ }
+}
\ No newline at end of file
diff --git a/src/CleanApiStarter.Api/Endpoints/V1/Words.cs b/src/CleanApiStarter.Api/Endpoints/V1/Words.cs
index 9aeb17c..0b8c011 100644
--- a/src/CleanApiStarter.Api/Endpoints/V1/Words.cs
+++ b/src/CleanApiStarter.Api/Endpoints/V1/Words.cs
@@ -8,6 +8,8 @@ public sealed class Words : IEndpointGroup
public static void Map(RouteGroupBuilder groupBuilder)
{
+ groupBuilder.RequireAuthorization();
+
groupBuilder.MapPost("/", CreateWord)
.WithName("CreateWordV1");
@@ -73,4 +75,4 @@ private static async Task DeleteWord(
return success ? Results.NoContent() : Results.NotFound();
}
-}
+}
\ No newline at end of file
diff --git a/src/CleanApiStarter.Api/Endpoints/V2/Words.cs b/src/CleanApiStarter.Api/Endpoints/V2/Words.cs
index c869ab6..27bd502 100644
--- a/src/CleanApiStarter.Api/Endpoints/V2/Words.cs
+++ b/src/CleanApiStarter.Api/Endpoints/V2/Words.cs
@@ -8,6 +8,8 @@ public sealed class Words : IEndpointGroup
public static void Map(RouteGroupBuilder groupBuilder)
{
+ groupBuilder.RequireAuthorization();
+
groupBuilder.MapGet("/", GetAllWords)
.WithName("GetAllWordsV2");
}
@@ -24,4 +26,4 @@ private static async Task GetAllWords(
Items = words
});
}
-}
+}
\ No newline at end of file
diff --git a/src/CleanApiStarter.Api/GlobalUsings.cs b/src/CleanApiStarter.Api/GlobalUsings.cs
index 01ef137..43234fb 100644
--- a/src/CleanApiStarter.Api/GlobalUsings.cs
+++ b/src/CleanApiStarter.Api/GlobalUsings.cs
@@ -1,10 +1,15 @@
// Global using directives
global using System.Reflection;
+global using System.Security.Claims;
+global using System.Text.Encodings.Web;
+global using CleanApiStarter.Api.Endpoints;
+global using CleanApiStarter.Api.Services;
global using CleanApiStarter.Application;
-global using CleanApiStarter.Application.Interfaces;
-global using CleanApiStarter.Application.Models;
+global using CleanApiStarter.Application.Common.Interfaces;
+global using CleanApiStarter.Application.Features.Auth;
+global using CleanApiStarter.Application.Features.Words;
global using CleanApiStarter.AspNetCore;
global using CleanApiStarter.Configuration;
global using CleanApiStarter.Infrastructure;
@@ -12,4 +17,4 @@
global using Microsoft.AspNetCore.Http;
global using Microsoft.AspNetCore.Routing;
-global using Scalar.AspNetCore;
+global using Scalar.AspNetCore;
\ No newline at end of file
diff --git a/src/CleanApiStarter.Api/Program.cs b/src/CleanApiStarter.Api/Program.cs
index 05392a3..d599159 100644
--- a/src/CleanApiStarter.Api/Program.cs
+++ b/src/CleanApiStarter.Api/Program.cs
@@ -2,12 +2,13 @@
builder.AddAspNetCoreDefaults();
-builder.Services.AddEndpointsApiExplorer();
-builder.Services.AddOpenApi();
-
-builder.Services.AddAppSettings(builder.Configuration);
-builder.Services.AddApplication();
-builder.Services.AddInfrastructure();
+builder.Services.AddEndpointsApiExplorer();
+builder.Services.AddOpenApi();
+
+builder.Services.AddAppSettings(builder.Configuration);
+builder.Services.AddApplication();
+builder.Services.AddInfrastructure();
+builder.Services.AddScoped();
WebApplication app = builder.Build();
@@ -20,7 +21,8 @@
app.UseAspNetCoreDefaults();
app.UseHttpsRedirection();
+app.MapGoogleLoginPage();
app.MapEndpoints(Assembly.GetExecutingAssembly());
app.MapDefaultEndpoints();
-app.Run();
+app.Run();
\ No newline at end of file
diff --git a/src/CleanApiStarter.Api/Services/CurrentUser.cs b/src/CleanApiStarter.Api/Services/CurrentUser.cs
new file mode 100644
index 0000000..c48f384
--- /dev/null
+++ b/src/CleanApiStarter.Api/Services/CurrentUser.cs
@@ -0,0 +1,9 @@
+namespace CleanApiStarter.Api.Services;
+
+public class CurrentUser(IHttpContextAccessor httpContextAccessor) : IUser
+{
+ public string? Id => httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier);
+
+ public List? Roles =>
+ httpContextAccessor.HttpContext?.User.FindAll(ClaimTypes.Role).Select(x => x.Value).ToList();
+}
\ No newline at end of file
diff --git a/src/CleanApiStarter.Api/appsettings.Development.json b/src/CleanApiStarter.Api/appsettings.Development.json
index 1690903..68309f2 100644
--- a/src/CleanApiStarter.Api/appsettings.Development.json
+++ b/src/CleanApiStarter.Api/appsettings.Development.json
@@ -1,5 +1,16 @@
{
"ConnectionStrings": {
"Postgres": "Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=postgres"
+ },
+ "Authentication": {
+ "Google": {
+ "ClientId": ""
+ },
+ "Jwt": {
+ "Issuer": "CleanApiStarter",
+ "Audience": "CleanApiStarter.Api",
+ "SigningKey": "development-only-signing-key-change-me",
+ "ExpirationMinutes": 60
+ }
}
}
diff --git a/src/CleanApiStarter.Application/Common/Interfaces/IUser.cs b/src/CleanApiStarter.Application/Common/Interfaces/IUser.cs
new file mode 100644
index 0000000..00c53cc
--- /dev/null
+++ b/src/CleanApiStarter.Application/Common/Interfaces/IUser.cs
@@ -0,0 +1,7 @@
+namespace CleanApiStarter.Application.Common.Interfaces;
+
+public interface IUser
+{
+ string? Id { get; }
+ List? Roles { get; }
+}
\ No newline at end of file
diff --git a/src/CleanApiStarter.Application/Features/Auth/AuthTokenDto.cs b/src/CleanApiStarter.Application/Features/Auth/AuthTokenDto.cs
new file mode 100644
index 0000000..7f32c89
--- /dev/null
+++ b/src/CleanApiStarter.Application/Features/Auth/AuthTokenDto.cs
@@ -0,0 +1,8 @@
+namespace CleanApiStarter.Application.Features.Auth;
+
+public sealed class AuthTokenDto
+{
+ public string AccessToken { get; init; } = string.Empty;
+
+ public DateTimeOffset ExpiresAt { get; init; }
+}
\ No newline at end of file
diff --git a/src/CleanApiStarter.Application/Features/Auth/CurrentUserDto.cs b/src/CleanApiStarter.Application/Features/Auth/CurrentUserDto.cs
new file mode 100644
index 0000000..f2618c7
--- /dev/null
+++ b/src/CleanApiStarter.Application/Features/Auth/CurrentUserDto.cs
@@ -0,0 +1,12 @@
+namespace CleanApiStarter.Application.Features.Auth;
+
+public sealed class CurrentUserDto
+{
+ public string UserId { get; init; } = string.Empty;
+
+ public string Email { get; init; } = string.Empty;
+
+ public string Name { get; init; } = string.Empty;
+
+ public IReadOnlyCollection Roles { get; init; } = [];
+}
\ No newline at end of file
diff --git a/src/CleanApiStarter.Application/Features/Auth/GoogleSignInDto.cs b/src/CleanApiStarter.Application/Features/Auth/GoogleSignInDto.cs
new file mode 100644
index 0000000..3eb4663
--- /dev/null
+++ b/src/CleanApiStarter.Application/Features/Auth/GoogleSignInDto.cs
@@ -0,0 +1,6 @@
+namespace CleanApiStarter.Application.Features.Auth;
+
+public sealed class GoogleSignInDto
+{
+ public string IdToken { get; init; } = string.Empty;
+}
\ No newline at end of file
diff --git a/src/CleanApiStarter.Application/Features/Auth/IAuthService.cs b/src/CleanApiStarter.Application/Features/Auth/IAuthService.cs
new file mode 100644
index 0000000..27bcb46
--- /dev/null
+++ b/src/CleanApiStarter.Application/Features/Auth/IAuthService.cs
@@ -0,0 +1,8 @@
+namespace CleanApiStarter.Application.Features.Auth;
+
+public interface IAuthService
+{
+ Task SignInWithGoogleAsync(GoogleSignInDto signInDto, CancellationToken cancellationToken);
+
+ Task GetCurrentUserAsync(ClaimsPrincipal principal, CancellationToken cancellationToken);
+}
\ No newline at end of file
diff --git a/src/CleanApiStarter.Application/Interfaces/IWordRepository.cs b/src/CleanApiStarter.Application/Features/Words/IWordRepository.cs
similarity index 88%
rename from src/CleanApiStarter.Application/Interfaces/IWordRepository.cs
rename to src/CleanApiStarter.Application/Features/Words/IWordRepository.cs
index c38a9c3..75aac8c 100644
--- a/src/CleanApiStarter.Application/Interfaces/IWordRepository.cs
+++ b/src/CleanApiStarter.Application/Features/Words/IWordRepository.cs
@@ -1,10 +1,10 @@
-namespace CleanApiStarter.Application.Interfaces;
-
-public interface IWordRepository
-{
- Task AddWordAsync(Word word, CancellationToken cancellationToken);
- Task GetWordByIdAsync(Guid id, CancellationToken cancellationToken);
- Task> GetAllWordsAsync(CancellationToken cancellationToken);
- Task UpdateWordAsync(Word word, CancellationToken cancellationToken);
- Task DeleteWordAsync(Guid id, CancellationToken cancellationToken);
+namespace CleanApiStarter.Application.Features.Words;
+
+public interface IWordRepository
+{
+ Task AddWordAsync(Word word, CancellationToken cancellationToken);
+ Task GetWordByIdAsync(Guid id, CancellationToken cancellationToken);
+ Task> GetAllWordsAsync(CancellationToken cancellationToken);
+ Task UpdateWordAsync(Word word, CancellationToken cancellationToken);
+ Task DeleteWordAsync(Guid id, CancellationToken cancellationToken);
}
\ No newline at end of file
diff --git a/src/CleanApiStarter.Application/Interfaces/IWordService.cs b/src/CleanApiStarter.Application/Features/Words/IWordService.cs
similarity index 88%
rename from src/CleanApiStarter.Application/Interfaces/IWordService.cs
rename to src/CleanApiStarter.Application/Features/Words/IWordService.cs
index 4ff4a63..8797d87 100644
--- a/src/CleanApiStarter.Application/Interfaces/IWordService.cs
+++ b/src/CleanApiStarter.Application/Features/Words/IWordService.cs
@@ -1,10 +1,10 @@
-namespace CleanApiStarter.Application.Interfaces;
-
-public interface IWordService
-{
- Task AddWordAsync(CreateWordDto wordDto, CancellationToken cancellationToken);
- Task GetWordByIdAsync(Guid id, CancellationToken cancellationToken);
- Task> GetAllWordsAsync(CancellationToken cancellationToken);
- Task UpdateWordAsync(Guid id, CreateWordDto wordDto, CancellationToken cancellationToken);
- Task DeleteWordAsync(Guid id, CancellationToken cancellationToken);
+namespace CleanApiStarter.Application.Features.Words;
+
+public interface IWordService
+{
+ Task AddWordAsync(CreateWordDto wordDto, CancellationToken cancellationToken);
+ Task GetWordByIdAsync(Guid id, CancellationToken cancellationToken);
+ Task> GetAllWordsAsync(CancellationToken cancellationToken);
+ Task UpdateWordAsync(Guid id, CreateWordDto wordDto, CancellationToken cancellationToken);
+ Task DeleteWordAsync(Guid id, CancellationToken cancellationToken);
}
\ No newline at end of file
diff --git a/src/CleanApiStarter.Application/Models/WordDto.cs b/src/CleanApiStarter.Application/Features/Words/WordDto.cs
similarity index 89%
rename from src/CleanApiStarter.Application/Models/WordDto.cs
rename to src/CleanApiStarter.Application/Features/Words/WordDto.cs
index 645bf2c..ef18d55 100644
--- a/src/CleanApiStarter.Application/Models/WordDto.cs
+++ b/src/CleanApiStarter.Application/Features/Words/WordDto.cs
@@ -1,18 +1,18 @@
-namespace CleanApiStarter.Application.Models;
-
-public class WordDto
-{
- public Guid Id { get; set; }
- public string Text { get; set; } = string.Empty;
- public string Meaning { get; set; } = string.Empty;
- public List Synonyms { get; set; } = new();
- public string UsageExample { get; set; } = string.Empty;
-}
-
-public class CreateWordDto
-{
- public string Text { get; set; } = string.Empty;
- public string Meaning { get; set; } = string.Empty;
- public List Synonyms { get; set; } = new();
- public string UsageExample { get; set; } = string.Empty;
+namespace CleanApiStarter.Application.Features.Words;
+
+public class WordDto
+{
+ public Guid Id { get; set; }
+ public string Text { get; set; } = string.Empty;
+ public string Meaning { get; set; } = string.Empty;
+ public List Synonyms { get; set; } = new();
+ public string UsageExample { get; set; } = string.Empty;
+}
+
+public class CreateWordDto
+{
+ public string Text { get; set; } = string.Empty;
+ public string Meaning { get; set; } = string.Empty;
+ public List Synonyms { get; set; } = new();
+ public string UsageExample { get; set; } = string.Empty;
}
\ No newline at end of file
diff --git a/src/CleanApiStarter.Application/Services/WordService.cs b/src/CleanApiStarter.Application/Features/Words/WordService.cs
similarity index 95%
rename from src/CleanApiStarter.Application/Services/WordService.cs
rename to src/CleanApiStarter.Application/Features/Words/WordService.cs
index 9f1b7fb..ceb05cd 100644
--- a/src/CleanApiStarter.Application/Services/WordService.cs
+++ b/src/CleanApiStarter.Application/Features/Words/WordService.cs
@@ -1,105 +1,105 @@
-namespace CleanApiStarter.Application.Services;
-
-public class WordService(IWordRepository wordRepository, ILogger logger) : IWordService
-{
- public async Task AddWordAsync(CreateWordDto wordDto, CancellationToken cancellationToken)
- {
- Word word = new()
- {
- Id = Guid.NewGuid(),
- Text = wordDto.Text,
- Meaning = wordDto.Meaning,
- Synonyms = wordDto.Synonyms,
- UsageExample = wordDto.UsageExample,
- CreatedAt = DateTime.UtcNow
- };
-
- logger.LogInformation(
- "Creating word {WordId} with {SynonymCount} synonyms",
- word.Id,
- word.Synonyms.Count);
-
- Guid wordId = await wordRepository.AddWordAsync(word, cancellationToken);
-
- logger.LogInformation("Created word {WordId}", wordId);
-
- return wordId;
- }
-
- public async Task GetWordByIdAsync(Guid id, CancellationToken cancellationToken)
- {
- Word? word = await wordRepository.GetWordByIdAsync(id, cancellationToken);
-
- if (word == null)
- {
- logger.LogWarning("Word {WordId} was not found", id);
- return null;
- }
-
- logger.LogInformation("Retrieved word {WordId}", id);
-
- return MapToDto(word);
- }
-
- public async Task> GetAllWordsAsync(CancellationToken cancellationToken)
- {
- IEnumerable words = await wordRepository.GetAllWordsAsync(cancellationToken);
- Word[] wordList = words.ToArray();
-
- logger.LogInformation("Retrieved {WordCount} words", wordList.Length);
-
- return wordList.Select(MapToDto);
- }
-
- public async Task UpdateWordAsync(Guid id, CreateWordDto wordDto, CancellationToken cancellationToken)
- {
- Word? existingWord = await wordRepository.GetWordByIdAsync(id, cancellationToken);
- if (existingWord == null)
- {
- logger.LogWarning("Word {WordId} could not be updated because it was not found", id);
- return false;
- }
-
- existingWord.Text = wordDto.Text;
- existingWord.Meaning = wordDto.Meaning;
- existingWord.Synonyms = wordDto.Synonyms;
- existingWord.UsageExample = wordDto.UsageExample;
- existingWord.UpdatedAt = DateTime.UtcNow;
-
- bool updated = await wordRepository.UpdateWordAsync(existingWord, cancellationToken);
-
- logger.LogInformation(
- "Updated word {WordId}; success: {UpdateSucceeded}",
- id,
- updated);
-
- return updated;
- }
-
- public async Task DeleteWordAsync(Guid id, CancellationToken cancellationToken)
- {
- bool deleted = await wordRepository.DeleteWordAsync(id, cancellationToken);
-
- if (!deleted)
- {
- logger.LogWarning("Word {WordId} could not be deleted because it was not found", id);
- return false;
- }
-
- logger.LogInformation("Deleted word {WordId}", id);
-
- return true;
- }
-
- private static WordDto MapToDto(Word word)
- {
- return new WordDto
- {
- Id = word.Id,
- Text = word.Text,
- Meaning = word.Meaning,
- Synonyms = word.Synonyms,
- UsageExample = word.UsageExample
- };
- }
+namespace CleanApiStarter.Application.Features.Words;
+
+public class WordService(IWordRepository wordRepository, ILogger logger) : IWordService
+{
+ public async Task AddWordAsync(CreateWordDto wordDto, CancellationToken cancellationToken)
+ {
+ Word word = new()
+ {
+ Id = Guid.NewGuid(),
+ Text = wordDto.Text,
+ Meaning = wordDto.Meaning,
+ Synonyms = wordDto.Synonyms,
+ UsageExample = wordDto.UsageExample,
+ CreatedAt = DateTime.UtcNow
+ };
+
+ logger.LogInformation(
+ "Creating word {WordId} with {SynonymCount} synonyms",
+ word.Id,
+ word.Synonyms.Count);
+
+ Guid wordId = await wordRepository.AddWordAsync(word, cancellationToken);
+
+ logger.LogInformation("Created word {WordId}", wordId);
+
+ return wordId;
+ }
+
+ public async Task GetWordByIdAsync(Guid id, CancellationToken cancellationToken)
+ {
+ Word? word = await wordRepository.GetWordByIdAsync(id, cancellationToken);
+
+ if (word == null)
+ {
+ logger.LogWarning("Word {WordId} was not found", id);
+ return null;
+ }
+
+ logger.LogInformation("Retrieved word {WordId}", id);
+
+ return MapToDto(word);
+ }
+
+ public async Task> GetAllWordsAsync(CancellationToken cancellationToken)
+ {
+ IEnumerable words = await wordRepository.GetAllWordsAsync(cancellationToken);
+ Word[] wordList = words.ToArray();
+
+ logger.LogInformation("Retrieved {WordCount} words", wordList.Length);
+
+ return wordList.Select(MapToDto);
+ }
+
+ public async Task UpdateWordAsync(Guid id, CreateWordDto wordDto, CancellationToken cancellationToken)
+ {
+ Word? existingWord = await wordRepository.GetWordByIdAsync(id, cancellationToken);
+ if (existingWord == null)
+ {
+ logger.LogWarning("Word {WordId} could not be updated because it was not found", id);
+ return false;
+ }
+
+ existingWord.Text = wordDto.Text;
+ existingWord.Meaning = wordDto.Meaning;
+ existingWord.Synonyms = wordDto.Synonyms;
+ existingWord.UsageExample = wordDto.UsageExample;
+ existingWord.UpdatedAt = DateTime.UtcNow;
+
+ bool updated = await wordRepository.UpdateWordAsync(existingWord, cancellationToken);
+
+ logger.LogInformation(
+ "Updated word {WordId}; success: {UpdateSucceeded}",
+ id,
+ updated);
+
+ return updated;
+ }
+
+ public async Task DeleteWordAsync(Guid id, CancellationToken cancellationToken)
+ {
+ bool deleted = await wordRepository.DeleteWordAsync(id, cancellationToken);
+
+ if (!deleted)
+ {
+ logger.LogWarning("Word {WordId} could not be deleted because it was not found", id);
+ return false;
+ }
+
+ logger.LogInformation("Deleted word {WordId}", id);
+
+ return true;
+ }
+
+ private static WordDto MapToDto(Word word)
+ {
+ return new WordDto
+ {
+ Id = word.Id,
+ Text = word.Text,
+ Meaning = word.Meaning,
+ Synonyms = word.Synonyms,
+ UsageExample = word.UsageExample
+ };
+ }
}
\ No newline at end of file
diff --git a/src/CleanApiStarter.Application/GlobalUsings.cs b/src/CleanApiStarter.Application/GlobalUsings.cs
index 2e2a517..b92085b 100644
--- a/src/CleanApiStarter.Application/GlobalUsings.cs
+++ b/src/CleanApiStarter.Application/GlobalUsings.cs
@@ -1,6 +1,6 @@
-global using CleanApiStarter.Application.Interfaces;
-global using CleanApiStarter.Application.Models;
-global using CleanApiStarter.Application.Services;
+global using System.Security.Claims;
+
+global using CleanApiStarter.Application.Features.Words;
global using CleanApiStarter.Domain.Entities;
global using Microsoft.Extensions.DependencyInjection;
diff --git a/src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs b/src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs
index 645a49b..101b619 100644
--- a/src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs
+++ b/src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs
@@ -6,6 +6,8 @@ private static void AddSecurityDefaults(this IHostApplicationBuilder builder)
{
builder.Services.Configure(
options => options.AddServerHeader = false);
+
+ builder.Services.AddHttpContextAccessor();
}
private static void AddProblemDetailsDefaults(this IHostApplicationBuilder builder)
@@ -19,24 +21,53 @@ private static void AddApiVersioningDefaults(this IHostApplicationBuilder builde
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1);
- options.AssumeDefaultVersionWhenUnspecified = false;
+ options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
options.ApiVersionReader = new HeaderApiVersionReader("X-Api-Version");
});
}
+ private static void AddAuthenticationDefaults(this WebApplicationBuilder builder)
+ {
+ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
+ .AddJwtBearer();
+
+ builder.Services.AddAuthorization();
+
+ builder.Services.AddOptions(JwtBearerDefaults.AuthenticationScheme)
+ .Configure((options, appSettings) =>
+ {
+ JwtAuthenticationSettings jwtSettings = appSettings.Authentication.Jwt;
+
+ options.TokenValidationParameters = new TokenValidationParameters
+ {
+ ValidateIssuer = true,
+ ValidIssuer = jwtSettings.Issuer,
+ ValidateAudience = true,
+ ValidAudience = jwtSettings.Audience,
+ ValidateIssuerSigningKey = true,
+ IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings.SigningKey)),
+ ValidateLifetime = true,
+ ClockSkew = TimeSpan.FromMinutes(1)
+ };
+ });
+ }
+
private static void AddHttpLoggingDefaults(this IHostApplicationBuilder builder)
{
builder.Logging.AddFilter("Microsoft.AspNetCore.HttpLogging", LogLevel.Information);
builder.Services.AddHttpLogging(options =>
{
+ options.CombineLogs = true;
options.LoggingFields =
HttpLoggingFields.RequestMethod |
HttpLoggingFields.RequestPath |
HttpLoggingFields.ResponseStatusCode |
HttpLoggingFields.Duration;
});
+
+ builder.Services.AddHttpLoggingInterceptor();
}
private static void AddServiceDiscoveryDefaults(this IHostApplicationBuilder builder)
diff --git a/src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj b/src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj
index 9247916..cf25978 100644
--- a/src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj
+++ b/src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj
@@ -5,9 +5,14 @@
CleanApiStarter.AspNetCore
+
+
+
+
+
diff --git a/src/CleanApiStarter.AspNetCore/Extensions.cs b/src/CleanApiStarter.AspNetCore/Extensions.cs
index 38f29f1..dd2b540 100644
--- a/src/CleanApiStarter.AspNetCore/Extensions.cs
+++ b/src/CleanApiStarter.AspNetCore/Extensions.cs
@@ -14,6 +14,7 @@ public static WebApplicationBuilder AddAspNetCoreDefaults(this WebApplicationBui
builder.AddSecurityDefaults();
builder.AddProblemDetailsDefaults();
builder.AddApiVersioningDefaults();
+ builder.AddAuthenticationDefaults();
builder.AddHttpLoggingDefaults();
builder.AddServiceDiscoveryDefaults();
builder.Services.AddHealthChecks()
@@ -25,7 +26,9 @@ public static WebApplicationBuilder AddAspNetCoreDefaults(this WebApplicationBui
public static WebApplication UseAspNetCoreDefaults(this WebApplication app)
{
app.UseExceptionHandler();
+ app.UseAuthentication();
app.UseHttpLogging();
+ app.UseAuthorization();
return app;
}
@@ -59,4 +62,4 @@ public static WebApplication MapDefaultEndpoints(this WebApplication app)
return app;
}
-}
+}
\ No newline at end of file
diff --git a/src/CleanApiStarter.AspNetCore/GlobalUsings.cs b/src/CleanApiStarter.AspNetCore/GlobalUsings.cs
index b863a3a..d12fd40 100644
--- a/src/CleanApiStarter.AspNetCore/GlobalUsings.cs
+++ b/src/CleanApiStarter.AspNetCore/GlobalUsings.cs
@@ -1,23 +1,29 @@
-global using System.Reflection;
-
-global using Asp.Versioning;
-global using Asp.Versioning.Builder;
-
-global using Microsoft.AspNetCore.Builder;
-global using Microsoft.AspNetCore.Diagnostics;
-global using Microsoft.AspNetCore.Diagnostics.HealthChecks;
-global using Microsoft.AspNetCore.Http;
-global using Microsoft.AspNetCore.HttpLogging;
-global using Microsoft.AspNetCore.Mvc;
-global using Microsoft.AspNetCore.Routing;
-global using Microsoft.Extensions.DependencyInjection;
-global using Microsoft.Extensions.Diagnostics.HealthChecks;
-global using Microsoft.Extensions.Hosting;
-global using Microsoft.Extensions.Logging;
+global using System.IdentityModel.Tokens.Jwt;
+global using System.Reflection;
+global using System.Security.Claims;
+global using System.Text;
+
+global using Asp.Versioning;
+global using Asp.Versioning.Builder;
+
+global using CleanApiStarter.Configuration;
+
+global using Microsoft.AspNetCore.Authentication.JwtBearer;
+global using Microsoft.AspNetCore.Builder;
+global using Microsoft.AspNetCore.Diagnostics;
+global using Microsoft.AspNetCore.Diagnostics.HealthChecks;
+global using Microsoft.AspNetCore.Http;
+global using Microsoft.AspNetCore.HttpLogging;
+global using Microsoft.AspNetCore.Mvc;
+global using Microsoft.AspNetCore.Routing;
+global using Microsoft.Extensions.DependencyInjection;
+global using Microsoft.Extensions.Diagnostics.HealthChecks;
+global using Microsoft.Extensions.Hosting;
+global using Microsoft.Extensions.Logging;
+global using Microsoft.IdentityModel.Tokens;
global using Npgsql;
-global using OpenTelemetry;
global using OpenTelemetry.Logs;
global using OpenTelemetry.Metrics;
-global using OpenTelemetry.Trace;
+global using OpenTelemetry.Trace;
\ No newline at end of file
diff --git a/src/CleanApiStarter.AspNetCore/IEndpointGroup.cs b/src/CleanApiStarter.AspNetCore/IEndpointGroup.cs
index 66a2135..cc4f2e9 100644
--- a/src/CleanApiStarter.AspNetCore/IEndpointGroup.cs
+++ b/src/CleanApiStarter.AspNetCore/IEndpointGroup.cs
@@ -7,4 +7,4 @@ public interface IEndpointGroup
static virtual string? RoutePrefix => null;
static abstract void Map(RouteGroupBuilder groupBuilder);
-}
+}
\ No newline at end of file
diff --git a/src/CleanApiStarter.AspNetCore/OpenTelemetryDefaults.cs b/src/CleanApiStarter.AspNetCore/OpenTelemetryDefaults.cs
index 70733a2..154ef68 100644
--- a/src/CleanApiStarter.AspNetCore/OpenTelemetryDefaults.cs
+++ b/src/CleanApiStarter.AspNetCore/OpenTelemetryDefaults.cs
@@ -38,4 +38,4 @@ private static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicat
return builder;
}
-}
+}
\ No newline at end of file
diff --git a/src/CleanApiStarter.AspNetCore/ProblemDetailsExceptionHandler.cs b/src/CleanApiStarter.AspNetCore/ProblemDetailsExceptionHandler.cs
index 0c2320c..4f270a9 100644
--- a/src/CleanApiStarter.AspNetCore/ProblemDetailsExceptionHandler.cs
+++ b/src/CleanApiStarter.AspNetCore/ProblemDetailsExceptionHandler.cs
@@ -1,6 +1,6 @@
namespace CleanApiStarter.AspNetCore;
-public sealed class ProblemDetailsExceptionHandler : IExceptionHandler
+public sealed class ProblemDetailsExceptionHandler(ILogger logger) : IExceptionHandler
{
public async ValueTask TryHandleAsync(
HttpContext httpContext,
@@ -46,6 +46,24 @@ public async ValueTask TryHandleAsync(
})
};
+ if (statusCode >= StatusCodes.Status500InternalServerError)
+ {
+ logger.LogError(
+ exception,
+ "Unhandled exception while processing {RequestMethod} {RequestPath}",
+ httpContext.Request.Method,
+ httpContext.Request.Path);
+ }
+ else
+ {
+ logger.LogWarning(
+ exception,
+ "Handled exception with status {StatusCode} while processing {RequestMethod} {RequestPath}",
+ statusCode,
+ httpContext.Request.Method,
+ httpContext.Request.Path);
+ }
+
httpContext.Response.StatusCode = statusCode;
await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
diff --git a/src/CleanApiStarter.AspNetCore/UserIdHttpLoggingInterceptor.cs b/src/CleanApiStarter.AspNetCore/UserIdHttpLoggingInterceptor.cs
new file mode 100644
index 0000000..4e8fff7
--- /dev/null
+++ b/src/CleanApiStarter.AspNetCore/UserIdHttpLoggingInterceptor.cs
@@ -0,0 +1,28 @@
+namespace CleanApiStarter.AspNetCore;
+
+public sealed class UserIdHttpLoggingInterceptor : IHttpLoggingInterceptor
+{
+ private const string UserIdParameterName = "UserId";
+ private const string AnonymousUserId = "anonymous";
+
+ public ValueTask OnRequestAsync(HttpLoggingInterceptorContext logContext)
+ {
+ logContext.AddParameter(UserIdParameterName, GetUserId(logContext.HttpContext));
+
+ return ValueTask.CompletedTask;
+ }
+
+ public ValueTask OnResponseAsync(HttpLoggingInterceptorContext logContext)
+ {
+ logContext.AddParameter(UserIdParameterName, GetUserId(logContext.HttpContext));
+
+ return ValueTask.CompletedTask;
+ }
+
+ private static string GetUserId(HttpContext httpContext)
+ {
+ return httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier)
+ ?? httpContext.User.FindFirstValue(JwtRegisteredClaimNames.Sub)
+ ?? AnonymousUserId;
+ }
+}
\ No newline at end of file
diff --git a/src/CleanApiStarter.AspNetCore/WebApplicationExtensions.cs b/src/CleanApiStarter.AspNetCore/WebApplicationExtensions.cs
index 59f4337..023c50b 100644
--- a/src/CleanApiStarter.AspNetCore/WebApplicationExtensions.cs
+++ b/src/CleanApiStarter.AspNetCore/WebApplicationExtensions.cs
@@ -36,4 +36,4 @@ private static ApiVersion GetApiVersion(Type endpointGroupType)
return new ApiVersion(majorVersion);
}
-}
+}
\ No newline at end of file
diff --git a/src/CleanApiStarter.Configuration/AppSettings.cs b/src/CleanApiStarter.Configuration/AppSettings.cs
index fd74519..a0f32ea 100644
--- a/src/CleanApiStarter.Configuration/AppSettings.cs
+++ b/src/CleanApiStarter.Configuration/AppSettings.cs
@@ -5,4 +5,8 @@ public sealed class AppSettings
[Required]
[ValidateObjectMembers]
public ConnectionStringSettings ConnectionStrings { get; set; } = new();
-}
+
+ [Required]
+ [ValidateObjectMembers]
+ public AuthenticationSettings Authentication { get; set; } = new();
+}
\ No newline at end of file
diff --git a/src/CleanApiStarter.Configuration/AuthenticationSettings.cs b/src/CleanApiStarter.Configuration/AuthenticationSettings.cs
new file mode 100644
index 0000000..ff38cd4
--- /dev/null
+++ b/src/CleanApiStarter.Configuration/AuthenticationSettings.cs
@@ -0,0 +1,12 @@
+namespace CleanApiStarter.Configuration;
+
+public sealed class AuthenticationSettings
+{
+ [Required]
+ [ValidateObjectMembers]
+ public GoogleAuthenticationSettings Google { get; set; } = new();
+
+ [Required]
+ [ValidateObjectMembers]
+ public JwtAuthenticationSettings Jwt { get; set; } = new();
+}
\ No newline at end of file
diff --git a/src/CleanApiStarter.Configuration/ConnectionStringSettings.cs b/src/CleanApiStarter.Configuration/ConnectionStringSettings.cs
index 1299016..deb6fbe 100644
--- a/src/CleanApiStarter.Configuration/ConnectionStringSettings.cs
+++ b/src/CleanApiStarter.Configuration/ConnectionStringSettings.cs
@@ -4,4 +4,4 @@ public sealed class ConnectionStringSettings
{
[Required]
public string Postgres { get; set; } = string.Empty;
-}
+}
\ No newline at end of file
diff --git a/src/CleanApiStarter.Configuration/GlobalUsings.cs b/src/CleanApiStarter.Configuration/GlobalUsings.cs
index a2250c9..1fd17fc 100644
--- a/src/CleanApiStarter.Configuration/GlobalUsings.cs
+++ b/src/CleanApiStarter.Configuration/GlobalUsings.cs
@@ -2,4 +2,4 @@
global using Microsoft.Extensions.Configuration;
global using Microsoft.Extensions.DependencyInjection;
-global using Microsoft.Extensions.Options;
+global using Microsoft.Extensions.Options;
\ No newline at end of file
diff --git a/src/CleanApiStarter.Configuration/GoogleAuthenticationSettings.cs b/src/CleanApiStarter.Configuration/GoogleAuthenticationSettings.cs
new file mode 100644
index 0000000..1fda7db
--- /dev/null
+++ b/src/CleanApiStarter.Configuration/GoogleAuthenticationSettings.cs
@@ -0,0 +1,7 @@
+namespace CleanApiStarter.Configuration;
+
+public sealed class GoogleAuthenticationSettings
+{
+ [Required]
+ public string ClientId { get; set; } = string.Empty;
+}
\ No newline at end of file
diff --git a/src/CleanApiStarter.Configuration/JwtAuthenticationSettings.cs b/src/CleanApiStarter.Configuration/JwtAuthenticationSettings.cs
new file mode 100644
index 0000000..9183996
--- /dev/null
+++ b/src/CleanApiStarter.Configuration/JwtAuthenticationSettings.cs
@@ -0,0 +1,17 @@
+namespace CleanApiStarter.Configuration;
+
+public sealed class JwtAuthenticationSettings
+{
+ [Required]
+ public string Issuer { get; set; } = string.Empty;
+
+ [Required]
+ public string Audience { get; set; } = string.Empty;
+
+ [Required]
+ [MinLength(32)]
+ public string SigningKey { get; set; } = string.Empty;
+
+ [Range(1, 1440)]
+ public int ExpirationMinutes { get; set; } = 60;
+}
\ No newline at end of file
diff --git a/src/CleanApiStarter.Configuration/OptionsRegistrationExtensions.cs b/src/CleanApiStarter.Configuration/OptionsRegistrationExtensions.cs
index 6b767c7..051f517 100644
--- a/src/CleanApiStarter.Configuration/OptionsRegistrationExtensions.cs
+++ b/src/CleanApiStarter.Configuration/OptionsRegistrationExtensions.cs
@@ -16,4 +16,4 @@ public static IServiceCollection AddAppSettings(
return services;
}
-}
+}
\ No newline at end of file
diff --git a/src/CleanApiStarter.Infrastructure/CleanApiStarter.Infrastructure.csproj b/src/CleanApiStarter.Infrastructure/CleanApiStarter.Infrastructure.csproj
index a814aa1..71d039f 100644
--- a/src/CleanApiStarter.Infrastructure/CleanApiStarter.Infrastructure.csproj
+++ b/src/CleanApiStarter.Infrastructure/CleanApiStarter.Infrastructure.csproj
@@ -11,8 +11,10 @@
-
-
+
+
+
+
diff --git a/src/CleanApiStarter.Infrastructure/Database/DatabaseConnectionFactory.cs b/src/CleanApiStarter.Infrastructure/Database/DatabaseConnectionFactory.cs
deleted file mode 100644
index b82d596..0000000
--- a/src/CleanApiStarter.Infrastructure/Database/DatabaseConnectionFactory.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-namespace CleanApiStarter.Infrastructure.Database;
-
-public interface IDatabaseConnectionFactory
-{
- IDbConnection CreateConnection();
-}
-
-public class PostgresConnectionFactory(AppSettings appSettings) : IDatabaseConnectionFactory
-{
- public IDbConnection CreateConnection()
- {
- return new NpgsqlConnection(appSettings.ConnectionStrings.Postgres);
- }
-}
diff --git a/src/CleanApiStarter.Infrastructure/DependencyInjection.cs b/src/CleanApiStarter.Infrastructure/DependencyInjection.cs
index b2a5e19..72cd87e 100644
--- a/src/CleanApiStarter.Infrastructure/DependencyInjection.cs
+++ b/src/CleanApiStarter.Infrastructure/DependencyInjection.cs
@@ -1,12 +1,22 @@
namespace CleanApiStarter.Infrastructure;
-public static class DependencyInjection
-{
- public static IServiceCollection AddInfrastructure(this IServiceCollection services)
- {
- services.AddSingleton();
- services.AddScoped();
-
- return services;
+public static class DependencyInjection
+{
+ public static IServiceCollection AddInfrastructure(this IServiceCollection services)
+ {
+ services.AddDbContext((serviceProvider, options) =>
+ {
+ AppSettings appSettings = serviceProvider.GetRequiredService();
+ options.UseNpgsql(appSettings.ConnectionStrings.Postgres);
+ });
+
+ services.AddIdentityCore()
+ .AddRoles()
+ .AddEntityFrameworkStores();
+
+ services.AddScoped();
+ services.AddScoped();
+
+ return services;
}
-}
+}
\ No newline at end of file
diff --git a/src/CleanApiStarter.Infrastructure/GlobalUsings.cs b/src/CleanApiStarter.Infrastructure/GlobalUsings.cs
index 0106a08..8e0a629 100644
--- a/src/CleanApiStarter.Infrastructure/GlobalUsings.cs
+++ b/src/CleanApiStarter.Infrastructure/GlobalUsings.cs
@@ -1,14 +1,21 @@
-global using System.Data;
+global using System.IdentityModel.Tokens.Jwt;
+global using System.Security.Claims;
+global using System.Text;
global using System.Text.Json;
-global using CleanApiStarter.Application.Interfaces;
+global using CleanApiStarter.Application.Features.Auth;
+global using CleanApiStarter.Application.Features.Words;
global using CleanApiStarter.Configuration;
global using CleanApiStarter.Domain.Entities;
-global using CleanApiStarter.Infrastructure.Database;
+global using CleanApiStarter.Infrastructure.Identity;
+global using CleanApiStarter.Infrastructure.Persistence;
global using CleanApiStarter.Infrastructure.Repositories;
-global using Dapper;
+global using Google.Apis.Auth;
+global using Microsoft.AspNetCore.Identity;
+global using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
+global using Microsoft.EntityFrameworkCore;
+global using Microsoft.EntityFrameworkCore.Metadata.Builders;
global using Microsoft.Extensions.DependencyInjection;
-
-global using Npgsql;
+global using Microsoft.IdentityModel.Tokens;
\ No newline at end of file
diff --git a/src/CleanApiStarter.Infrastructure/Identity/ApplicationUser.cs b/src/CleanApiStarter.Infrastructure/Identity/ApplicationUser.cs
new file mode 100644
index 0000000..2996a8e
--- /dev/null
+++ b/src/CleanApiStarter.Infrastructure/Identity/ApplicationUser.cs
@@ -0,0 +1,3 @@
+namespace CleanApiStarter.Infrastructure.Identity;
+
+public sealed class ApplicationUser : IdentityUser;
\ No newline at end of file
diff --git a/src/CleanApiStarter.Infrastructure/Identity/GoogleAuthService.cs b/src/CleanApiStarter.Infrastructure/Identity/GoogleAuthService.cs
new file mode 100644
index 0000000..d6e61ce
--- /dev/null
+++ b/src/CleanApiStarter.Infrastructure/Identity/GoogleAuthService.cs
@@ -0,0 +1,151 @@
+namespace CleanApiStarter.Infrastructure.Identity;
+
+public sealed class GoogleAuthService(
+ AppSettings appSettings,
+ UserManager userManager,
+ RoleManager roleManager) : IAuthService
+{
+ private const string GoogleLoginProvider = "Google";
+ private const string DefaultRole = "User";
+
+ public async Task SignInWithGoogleAsync(GoogleSignInDto signInDto, CancellationToken cancellationToken)
+ {
+ GoogleJsonWebSignature.Payload payload = await ValidateGoogleTokenAsync(signInDto.IdToken);
+ ApplicationUser user = await FindOrCreateUserAsync(payload, cancellationToken);
+ IList roles = await userManager.GetRolesAsync(user);
+ DateTimeOffset expiresAt = DateTimeOffset.UtcNow.AddMinutes(appSettings.Authentication.Jwt.ExpirationMinutes);
+
+ return new AuthTokenDto
+ {
+ AccessToken = CreateAccessToken(user, payload.Name, roles, expiresAt),
+ ExpiresAt = expiresAt
+ };
+ }
+
+ public Task GetCurrentUserAsync(ClaimsPrincipal principal, CancellationToken cancellationToken)
+ {
+ string userId = principal.FindFirstValue(ClaimTypes.NameIdentifier) ?? string.Empty;
+ string email = principal.FindFirstValue(ClaimTypes.Email) ?? string.Empty;
+ string name = principal.FindFirstValue(ClaimTypes.Name) ?? string.Empty;
+ string[] roles = principal.FindAll(ClaimTypes.Role)
+ .Select(claim => claim.Value)
+ .ToArray();
+
+ return Task.FromResult(new CurrentUserDto
+ {
+ UserId = userId,
+ Email = email,
+ Name = name,
+ Roles = roles
+ });
+ }
+
+ private async Task ValidateGoogleTokenAsync(string idToken)
+ {
+ GoogleJsonWebSignature.ValidationSettings validationSettings = new()
+ {
+ Audience = [appSettings.Authentication.Google.ClientId]
+ };
+
+ return await GoogleJsonWebSignature.ValidateAsync(idToken, validationSettings);
+ }
+
+ private async Task FindOrCreateUserAsync(
+ GoogleJsonWebSignature.Payload payload,
+ CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(payload.Email) || !payload.EmailVerified)
+ {
+ throw new UnauthorizedAccessException("Google account email must be verified.");
+ }
+
+ ApplicationUser? user = await userManager.FindByLoginAsync(GoogleLoginProvider, payload.Subject);
+
+ if (user == null)
+ {
+ user = await userManager.FindByEmailAsync(payload.Email);
+ }
+
+ if (user == null)
+ {
+ user = new ApplicationUser
+ {
+ UserName = payload.Email,
+ Email = payload.Email,
+ EmailConfirmed = payload.EmailVerified
+ };
+
+ IdentityResult createResult = await userManager.CreateAsync(user);
+ ThrowIfFailed(createResult);
+ }
+
+ UserLoginInfo googleLogin = new(GoogleLoginProvider, payload.Subject, GoogleLoginProvider);
+ IList logins = await userManager.GetLoginsAsync(user);
+
+ if (!logins.Any(login => login.LoginProvider == GoogleLoginProvider && login.ProviderKey == payload.Subject))
+ {
+ IdentityResult loginResult = await userManager.AddLoginAsync(user, googleLogin);
+ ThrowIfFailed(loginResult);
+ }
+
+ await EnsureDefaultRoleAsync(user);
+
+ return user;
+ }
+
+ private async Task EnsureDefaultRoleAsync(ApplicationUser user)
+ {
+ if (!await roleManager.RoleExistsAsync(DefaultRole))
+ {
+ IdentityResult roleResult = await roleManager.CreateAsync(new IdentityRole(DefaultRole));
+ ThrowIfFailed(roleResult);
+ }
+
+ if (!await userManager.IsInRoleAsync(user, DefaultRole))
+ {
+ IdentityResult userRoleResult = await userManager.AddToRoleAsync(user, DefaultRole);
+ ThrowIfFailed(userRoleResult);
+ }
+ }
+
+ private string CreateAccessToken(
+ ApplicationUser user,
+ string? googleName,
+ IEnumerable roles,
+ DateTimeOffset expiresAt)
+ {
+ JwtAuthenticationSettings jwtSettings = appSettings.Authentication.Jwt;
+ SymmetricSecurityKey securityKey = new(Encoding.UTF8.GetBytes(jwtSettings.SigningKey));
+ SigningCredentials signingCredentials = new(securityKey, SecurityAlgorithms.HmacSha256);
+ List claims =
+ [
+ new(JwtRegisteredClaimNames.Sub, user.Id),
+ new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
+ new(ClaimTypes.NameIdentifier, user.Id),
+ new(ClaimTypes.Email, user.Email ?? string.Empty),
+ new(ClaimTypes.Name, googleName ?? user.UserName ?? string.Empty)
+ ];
+
+ claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
+
+ JwtSecurityToken token = new(
+ issuer: jwtSettings.Issuer,
+ audience: jwtSettings.Audience,
+ claims: claims,
+ expires: expiresAt.UtcDateTime,
+ signingCredentials: signingCredentials);
+
+ return new JwtSecurityTokenHandler().WriteToken(token);
+ }
+
+ private static void ThrowIfFailed(IdentityResult result)
+ {
+ if (result.Succeeded)
+ {
+ return;
+ }
+
+ string errors = string.Join("; ", result.Errors.Select(error => error.Description));
+ throw new InvalidOperationException(errors);
+ }
+}
\ No newline at end of file
diff --git a/src/CleanApiStarter.Infrastructure/Persistence/ApplicationDbContext.cs b/src/CleanApiStarter.Infrastructure/Persistence/ApplicationDbContext.cs
new file mode 100644
index 0000000..b1d19b2
--- /dev/null
+++ b/src/CleanApiStarter.Infrastructure/Persistence/ApplicationDbContext.cs
@@ -0,0 +1,14 @@
+namespace CleanApiStarter.Infrastructure.Persistence;
+
+public sealed class ApplicationDbContext(DbContextOptions options)
+ : IdentityDbContext(options)
+{
+ public DbSet Words => Set();
+
+ protected override void OnModelCreating(ModelBuilder builder)
+ {
+ base.OnModelCreating(builder);
+
+ builder.ApplyConfigurationsFromAssembly(typeof(ApplicationDbContext).Assembly);
+ }
+}
\ No newline at end of file
diff --git a/src/CleanApiStarter.Infrastructure/Persistence/Configuration/WordConfiguration.cs b/src/CleanApiStarter.Infrastructure/Persistence/Configuration/WordConfiguration.cs
new file mode 100644
index 0000000..61ee5f4
--- /dev/null
+++ b/src/CleanApiStarter.Infrastructure/Persistence/Configuration/WordConfiguration.cs
@@ -0,0 +1,40 @@
+namespace CleanApiStarter.Infrastructure.Persistence.Configuration;
+
+public sealed class WordConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("words");
+
+ builder.HasKey(word => word.Id);
+
+ builder.Property(word => word.Id)
+ .HasColumnName("id");
+
+ builder.Property(word => word.Text)
+ .HasColumnName("text")
+ .HasMaxLength(100)
+ .IsRequired();
+
+ builder.Property(word => word.Meaning)
+ .HasColumnName("meaning")
+ .IsRequired();
+
+ builder.Property(word => word.Synonyms)
+ .HasColumnName("synonyms")
+ .HasColumnType("jsonb")
+ .HasConversion(
+ synonyms => JsonSerializer.Serialize(synonyms, JsonSerializerOptions.Default),
+ value => JsonSerializer.Deserialize>(value, JsonSerializerOptions.Default) ?? new List());
+
+ builder.Property(word => word.UsageExample)
+ .HasColumnName("usage_example")
+ .IsRequired();
+
+ builder.Property(word => word.CreatedAt)
+ .HasColumnName("created_at");
+
+ builder.Property(word => word.UpdatedAt)
+ .HasColumnName("updated_at");
+ }
+}
\ No newline at end of file
diff --git a/src/CleanApiStarter.Infrastructure/Repositories/WordRepository.cs b/src/CleanApiStarter.Infrastructure/Repositories/WordRepository.cs
index ce6b6cf..d2c6334 100644
--- a/src/CleanApiStarter.Infrastructure/Repositories/WordRepository.cs
+++ b/src/CleanApiStarter.Infrastructure/Repositories/WordRepository.cs
@@ -1,121 +1,44 @@
namespace CleanApiStarter.Infrastructure.Repositories;
-public class WordRepository(IDatabaseConnectionFactory connectionFactory) : IWordRepository
+public class WordRepository(ApplicationDbContext dbContext) : IWordRepository
{
public async Task AddWordAsync(Word word, CancellationToken cancellationToken)
{
- const string sql =
- """
- INSERT INTO words (id, text, meaning, synonyms, usage_example, created_at, updated_at)
- VALUES (@Id, @Text, @Meaning, @Synonyms::jsonb, @UsageExample, @CreatedAt, @UpdatedAt)
- RETURNING id
- """;
+ dbContext.Words.Add(word);
+ await dbContext.SaveChangesAsync(cancellationToken);
- using IDbConnection connection = connectionFactory.CreateConnection();
-
- var parameters = new
- {
- word.Id,
- word.Text,
- word.Meaning,
- Synonyms = JsonSerializer.Serialize(word.Synonyms),
- word.UsageExample,
- word.CreatedAt,
- word.UpdatedAt
- };
-
- Guid id = await connection.ExecuteScalarAsync(sql, parameters);
- return id;
+ return word.Id;
}
public async Task GetWordByIdAsync(Guid id, CancellationToken cancellationToken)
{
- const string sql =
- """
- SELECT id, text, meaning, synonyms::text as SynonymsJson, usage_example as UsageExample, created_at as CreatedAt, updated_at as UpdatedAt
- FROM words
- WHERE id = @Id
- """;
-
- using IDbConnection connection = connectionFactory.CreateConnection();
- WordRow? word = await connection.QuerySingleOrDefaultAsync(sql, new { Id = id });
-
- return word?.ToDomain();
+ return await dbContext.Words
+ .AsNoTracking()
+ .SingleOrDefaultAsync(word => word.Id == id, cancellationToken);
}
public async Task> GetAllWordsAsync(CancellationToken cancellationToken)
{
- const string sql =
- """
- SELECT id, text, meaning, synonyms::text as SynonymsJson, usage_example as UsageExample, created_at as CreatedAt, updated_at as UpdatedAt
- FROM words
- """;
-
- using IDbConnection connection = connectionFactory.CreateConnection();
- IEnumerable words = await connection.QueryAsync(sql);
-
- return words.Select(word => word.ToDomain());
+ return await dbContext.Words
+ .AsNoTracking()
+ .OrderBy(word => word.Text)
+ .ToListAsync(cancellationToken);
}
public async Task UpdateWordAsync(Word word, CancellationToken cancellationToken)
{
- const string sql =
- """
- UPDATE words
- SET text = @Text,
- meaning = @Meaning,
- synonyms = @Synonyms,
- usage_example = @UsageExample,
- updated_at = @UpdatedAt
- WHERE id = @Id
- """;
+ dbContext.Words.Update(word);
+ int rowsAffected = await dbContext.SaveChangesAsync(cancellationToken);
- using IDbConnection connection = connectionFactory.CreateConnection();
- var parameters = new
- {
- word.Id,
- word.Text,
- word.Meaning,
- Synonyms = JsonSerializer.Serialize(word.Synonyms),
- word.UsageExample,
- word.UpdatedAt
- };
-
- int rowsAffected = await connection.ExecuteAsync(sql, parameters);
return rowsAffected > 0;
}
public async Task DeleteWordAsync(Guid id, CancellationToken cancellationToken)
{
- const string sql = "DELETE FROM words WHERE id = @Id";
+ int rowsAffected = await dbContext.Words
+ .Where(word => word.Id == id)
+ .ExecuteDeleteAsync(cancellationToken);
- using IDbConnection connection = connectionFactory.CreateConnection();
- int rowsAffected = await connection.ExecuteAsync(sql, new { Id = id });
return rowsAffected > 0;
}
-
- private sealed class WordRow
- {
- public Guid Id { get; init; }
- public string Text { get; init; } = string.Empty;
- public string Meaning { get; init; } = string.Empty;
- public string SynonymsJson { get; init; } = string.Empty;
- public string UsageExample { get; init; } = string.Empty;
- public DateTime CreatedAt { get; init; }
- public DateTime? UpdatedAt { get; init; }
-
- public Word ToDomain()
- {
- return new Word
- {
- Id = Id,
- Text = Text,
- Meaning = Meaning,
- Synonyms = JsonSerializer.Deserialize>(SynonymsJson) ?? [],
- UsageExample = UsageExample,
- CreatedAt = CreatedAt,
- UpdatedAt = UpdatedAt
- };
- }
- }
}
\ No newline at end of file
diff --git a/tests/CleanApiStarter.UnitTests/Controllers/WordsControllerTests.cs b/tests/CleanApiStarter.UnitTests/Controllers/WordsControllerTests.cs
deleted file mode 100644
index 356aa00..0000000
--- a/tests/CleanApiStarter.UnitTests/Controllers/WordsControllerTests.cs
+++ /dev/null
@@ -1,131 +0,0 @@
-// using CleanApiStarter.Api.Controllers;
-// using CleanApiStarter.Application.Interfaces;
-// using CleanApiStarter.Application.Models;
-// using AutoFixture.Xunit3;
-// using FluentAssertions;
-// using Microsoft.AspNetCore.Mvc;
-// using NSubstitute;
-// using Xunit;
-//
-// namespace CleanApiStarter.UnitTests.Controllers;
-//
-// public class WordsControllerTests
-// {
-// private readonly IWordService _wordService;
-// private readonly WordsController _sut;
-//
-// public WordsControllerTests()
-// {
-// _wordService = Substitute.For();
-// _sut = new WordsController(_wordService);
-// }
-//
-// [Theory, AutoData]
-// public async Task CreateWord_ShouldReturnCreatedAtAction(CreateWordDto wordDto, Guid wordId)
-// {
-// // Arrange
-// _wordService.AddWordAsync(wordDto).Returns(wordId);
-//
-// // Act
-// var result = await _sut.CreateWord(wordDto);
-//
-// // Assert
-// var createdAtActionResult = result.Result.Should().BeOfType().Subject;
-// createdAtActionResult.ActionName.Should().Be(nameof(WordsController.GetWord));
-// createdAtActionResult.RouteValues!.Should().ContainKey("id").WhoseValue.Should().Be(wordId);
-// createdAtActionResult.Value.Should().Be(wordId);
-// }
-//
-// [Theory, AutoData]
-// public async Task GetWord_WhenWordExists_ShouldReturnOkWithWord(Guid wordId, WordDto wordDto)
-// {
-// // Arrange
-// _wordService.GetWordByIdAsync(wordId).Returns(wordDto);
-//
-// // Act
-// var result = await _sut.GetWord(wordId);
-//
-// // Assert
-// var okResult = result.Result.Should().BeOfType().Subject;
-// okResult.Value.Should().Be(wordDto);
-// }
-//
-// [Theory, AutoData]
-// public async Task GetWord_WhenWordDoesNotExist_ShouldReturnNotFound(Guid wordId)
-// {
-// // Arrange
-// _wordService.GetWordByIdAsync(wordId).Returns((WordDto?)null);
-//
-// // Act
-// var result = await _sut.GetWord(wordId);
-//
-// // Assert
-// result.Result.Should().BeOfType();
-// }
-//
-// [Theory, AutoData]
-// public async Task GetAllWords_ShouldReturnOkWithAllWords(List words)
-// {
-// // Arrange
-// _wordService.GetAllWordsAsync().Returns(words);
-//
-// // Act
-// var result = await _sut.GetAllWords();
-//
-// // Assert
-// var okResult = result.Result.Should().BeOfType().Subject;
-// okResult.Value.Should().Be(words);
-// }
-//
-// [Theory, AutoData]
-// public async Task UpdateWord_WhenWordExists_ShouldReturnNoContent(Guid wordId, CreateWordDto updateDto)
-// {
-// // Arrange
-// _wordService.UpdateWordAsync(wordId, updateDto).Returns(true);
-//
-// // Act
-// var result = await _sut.UpdateWord(wordId, updateDto);
-//
-// // Assert
-// result.Should().BeOfType();
-// }
-//
-// [Theory, AutoData]
-// public async Task UpdateWord_WhenWordDoesNotExist_ShouldReturnNotFound(Guid wordId, CreateWordDto updateDto)
-// {
-// // Arrange
-// _wordService.UpdateWordAsync(wordId, updateDto).Returns(false);
-//
-// // Act
-// var result = await _sut.UpdateWord(wordId, updateDto);
-//
-// // Assert
-// result.Should().BeOfType();
-// }
-//
-// [Theory, AutoData]
-// public async Task DeleteWord_WhenWordExists_ShouldReturnNoContent(Guid wordId)
-// {
-// // Arrange
-// _wordService.DeleteWordAsync(wordId).Returns(true);
-//
-// // Act
-// var result = await _sut.DeleteWord(wordId);
-//
-// // Assert
-// result.Should().BeOfType();
-// }
-//
-// [Theory, AutoData]
-// public async Task DeleteWord_WhenWordDoesNotExist_ShouldReturnNotFound(Guid wordId)
-// {
-// // Arrange
-// _wordService.DeleteWordAsync(wordId).Returns(false);
-//
-// // Act
-// var result = await _sut.DeleteWord(wordId);
-//
-// // Assert
-// result.Should().BeOfType();
-// }
-// }
\ No newline at end of file
diff --git a/tests/CleanApiStarter.UnitTests/Repositories/WordRepositoryTests.cs b/tests/CleanApiStarter.UnitTests/Repositories/WordRepositoryTests.cs
deleted file mode 100644
index 50d024d..0000000
--- a/tests/CleanApiStarter.UnitTests/Repositories/WordRepositoryTests.cs
+++ /dev/null
@@ -1,61 +0,0 @@
-// using System.Data;
-// using AutoFixture.Xunit3;
-// using CleanApiStarter.Domain.Entities;
-// using FluentAssertions;
-// using CleanApiStarter.Infrastructure.Database;
-// using CleanApiStarter.Infrastructure.Repositories;
-// using NSubstitute;
-// using Xunit;
-//
-// namespace CleanApiStarter.UnitTests.Repositories;
-//
-// public class WordRepositoryTests
-// {
-// private readonly IDatabaseConnectionFactory _connectionFactory;
-// private readonly IDbConnection _connection;
-// private readonly WordRepository _sut;
-//
-// public WordRepositoryTests()
-// {
-// _connection = Substitute.For();
-// _connectionFactory = Substitute.For();
-// _connectionFactory.CreateConnection().Returns(_connection);
-//
-// _sut = new WordRepository(_connectionFactory);
-// }
-//
-// [Theory, AutoData]
-// public async Task AddWordAsync_ShouldOpenConnectionAndExecuteCommand(Word word)
-// {
-// // This test is a basic structure to show how to mock Dapper
-// // Full implementation would require more complex mocking of connection and command
-//
-// // Arrange
-// var expectedId = word.Id;
-//
-// // Act
-// await _sut.AddWordAsync(word);
-//
-// // Assert
-// _connectionFactory.Received(1).CreateConnection();
-// }
-//
-// [Theory, AutoData]
-// public async Task GetWordByIdAsync_ShouldOpenConnectionAndExecuteQuery(Guid id)
-// {
-// // This test is a basic structure to show how to mock Dapper
-// // Full implementation would require more complex mocking of connection and query
-//
-// // Arrange
-//
-// // Act
-// await _sut.GetWordByIdAsync(id);
-//
-// // Assert
-// _connectionFactory.Received(1).CreateConnection();
-// }
-//
-// // Additional tests would follow the same pattern
-// // For a real implementation, you would need to mock Dapper's extension methods
-// // which is more complex and may require a test database or more sophisticated mocking
-// }
\ No newline at end of file
diff --git a/tests/CleanApiStarter.UnitTests/Services/WordServiceTests.cs b/tests/CleanApiStarter.UnitTests/Services/WordServiceTests.cs
deleted file mode 100644
index 601e4a5..0000000
--- a/tests/CleanApiStarter.UnitTests/Services/WordServiceTests.cs
+++ /dev/null
@@ -1,139 +0,0 @@
-// using CleanApiStarter.Application.Interfaces;
-// using CleanApiStarter.Application.Models;
-// using CleanApiStarter.Application.Services;
-//
-// using CleanApiStarter.Domain.Entities;
-//
-// using NSubstitute;
-//
-// using Shouldly;
-//
-// using Xunit;
-//
-// namespace CleanApiStarter.UnitTests.Services;
-//
-// public class WordServiceTests
-// {
-// private readonly IWordRepository _wordRepository;
-// private readonly WordService _sut;
-//
-// public WordServiceTests()
-// {
-// _wordRepository = Substitute.For();
-// _sut = new WordService(_wordRepository);
-// }
-//
-// [Theory, AutoData]
-// public async Task AddWordAsync_ShouldMapDtoToDomainAndCallRepository(CreateWordDto wordDto)
-// {
-// // Arrange
-// var expectedId = Guid.NewGuid();
-// _wordRepository.AddWordAsync(Arg.Any()).Returns(expectedId);
-//
-// // Act
-// var result = await _sut.AddWordAsync(wordDto);
-//
-// // Assert
-// result.ShouldBe(expectedId);
-// await _wordRepository.Received(1).AddWordAsync(Arg.Is(w =>
-// w.Text == wordDto.Text &&
-// w.Meaning == wordDto.Meaning &&
-// w.Synonyms.SequenceEqual(wordDto.Synonyms) &&
-// w.UsageExample == wordDto.UsageExample));
-// }
-//
-// [Theory, AutoData]
-// public async Task GetWordByIdAsync_WhenWordExists_ShouldReturnMappedDto(Guid id, Word word)
-// {
-// // Arrange
-// _wordRepository.GetWordByIdAsync(id).Returns(word);
-//
-// // Act
-// var result = await _sut.GetWordByIdAsync(id);
-//
-// // Assert
-// result.Should().NotBeNull();
-// result!.Id.Should().Be(word.Id);
-// result.Text.Should().Be(word.Text);
-// result.Meaning.Should().Be(word.Meaning);
-// result.Synonyms.Should().BeEquivalentTo(word.Synonyms);
-// result.UsageExample.Should().Be(word.UsageExample);
-// }
-//
-// [Theory, AutoData]
-// public async Task GetWordByIdAsync_WhenWordDoesNotExist_ShouldReturnNull(Guid id)
-// {
-// // Arrange
-// _wordRepository.GetWordByIdAsync(id).Returns((Word?)null);
-//
-// // Act
-// var result = await _sut.GetWordByIdAsync(id);
-//
-// // Assert
-// result.Should().BeNull();
-// }
-//
-// [Theory, AutoData]
-// public async Task GetAllWordsAsync_ShouldReturnMappedDtos(List words)
-// {
-// // Arrange
-// _wordRepository.GetAllWordsAsync().Returns(words);
-//
-// // Act
-// var result = await _sut.GetAllWordsAsync();
-//
-// // Assert
-// result.Should().HaveCount(words.Count);
-// result.Select(dto => dto.Id).Should().BeEquivalentTo(words.Select(w => w.Id));
-// }
-//
-// [Theory, AutoData]
-// public async Task UpdateWordAsync_WhenWordExists_ShouldUpdateAndReturnTrue(Guid id, CreateWordDto updateDto, Word existingWord)
-// {
-// // Arrange
-// existingWord.Id = id;
-// _wordRepository.GetWordByIdAsync(id).Returns(existingWord);
-// _wordRepository.UpdateWordAsync(Arg.Any()).Returns(true);
-//
-// // Act
-// var result = await _sut.UpdateWordAsync(id, updateDto);
-//
-// // Assert
-// result.Should().BeTrue();
-// await _wordRepository.Received(1).UpdateWordAsync(Arg.Is(w =>
-// w.Id == id &&
-// w.Text == updateDto.Text &&
-// w.Meaning == updateDto.Meaning &&
-// w.Synonyms.SequenceEqual(updateDto.Synonyms) &&
-// w.UsageExample == updateDto.UsageExample &&
-// w.UpdatedAt != null));
-// }
-//
-// [Theory, AutoData]
-// public async Task UpdateWordAsync_WhenWordDoesNotExist_ShouldReturnFalse(Guid id, CreateWordDto updateDto)
-// {
-// // Arrange
-// _wordRepository.GetWordByIdAsync(id).Returns((Word?)null);
-//
-// // Act
-// var result = await _sut.UpdateWordAsync(id, updateDto);
-//
-// // Assert
-// result.Should().BeFalse();
-// await _wordRepository.DidNotReceive().UpdateWordAsync(Arg.Any());
-// }
-//
-// [Theory, AutoData]
-// public async Task DeleteWordAsync_ShouldCallRepositoryAndReturnResult(Guid id, bool expectedResult)
-// {
-// // Arrange
-// _wordRepository.DeleteWordAsync(id).Returns(expectedResult);
-//
-// // Act
-// var result = await _sut.DeleteWordAsync(id);
-//
-// // Assert
-// result.Should().Be(expectedResult);
-// await _wordRepository.Received(1).DeleteWordAsync(id);
-// }
-// }
\ No newline at end of file
From d4111733c7b77243b15609cb0bf70e517e95d6cf Mon Sep 17 00:00:00 2001
From: cbjpdev <42979311+cstacklab@users.noreply.github.com>
Date: Fri, 1 May 2026 10:10:16 +0800
Subject: [PATCH 06/33] implement pagination and validation for word endpoints,
add response compression and request ID middleware
---
AGENTS.md | 20 ++++++---
Directory.Packages.props | 8 +++-
README.md | 7 +++
src/CleanApiStarter.Api/Api.http | 5 ++-
.../CleanApiStarter.Api.csproj | 2 +
.../Endpoints/GoogleLoginPage.cs | 39 +++++++++++++++-
src/CleanApiStarter.Api/Endpoints/V1/Words.cs | 9 ++--
src/CleanApiStarter.Api/Endpoints/V2/Words.cs | 16 ++++---
src/CleanApiStarter.Api/GlobalUsings.cs | 5 ++-
src/CleanApiStarter.Api/Program.cs | 38 ++++++++++------
.../CleanApiStarter.Application.csproj | 2 +
.../Common/Models/ArrayResult.cs | 8 ++++
.../Common/Models/PaginatedQuery.cs | 8 ++++
.../Common/Models/PaginatedQueryValidator.cs | 13 ++++++
.../Common/Models/PaginatedResult.cs | 27 +++++++++++
.../DependencyInjection.cs | 2 +
.../Features/Auth/GoogleSignInDtoValidator.cs | 10 +++++
.../Features/Words/CreateWordDtoValidator.cs | 24 ++++++++++
.../Features/Words/IWordRepository.cs | 2 +-
.../Features/Words/IWordService.cs | 2 +-
.../Features/Words/WordService.cs | 16 ++++---
.../GlobalUsings.cs | 3 ++
.../AspNetCoreDefaultServices.cs | 37 ++++++++++++++-
.../CleanApiStarter.AspNetCore.csproj | 3 ++
src/CleanApiStarter.AspNetCore/Extensions.cs | 3 ++
.../GlobalUsings.cs | 13 ++++--
.../RequestIdMiddleware.cs | 23 ++++++++++
.../ValidationFilter.cs | 45 +++++++++++++++++++
.../WebApplicationExtensions.cs | 3 +-
.../GlobalUsings.cs | 1 +
.../Repositories/WordRepository.cs | 19 ++++++--
31 files changed, 362 insertions(+), 51 deletions(-)
create mode 100644 src/CleanApiStarter.Application/Common/Models/ArrayResult.cs
create mode 100644 src/CleanApiStarter.Application/Common/Models/PaginatedQuery.cs
create mode 100644 src/CleanApiStarter.Application/Common/Models/PaginatedQueryValidator.cs
create mode 100644 src/CleanApiStarter.Application/Common/Models/PaginatedResult.cs
create mode 100644 src/CleanApiStarter.Application/Features/Auth/GoogleSignInDtoValidator.cs
create mode 100644 src/CleanApiStarter.Application/Features/Words/CreateWordDtoValidator.cs
create mode 100644 src/CleanApiStarter.AspNetCore/RequestIdMiddleware.cs
create mode 100644 src/CleanApiStarter.AspNetCore/ValidationFilter.cs
diff --git a/AGENTS.md b/AGENTS.md
index edbeebc..9e6e59d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -51,11 +51,19 @@ This repository is a Clean Architecture API starter template named `CleanApiStar
- Organize the `Application` project by feature. Put feature-specific contracts, DTOs, and application services under folders such as `Application/Features/Words` or `Application/Features/Auth`.
- Do not create generic `Application/Services` or `Application/Models` folders for feature-specific code.
- Keep cross-cutting application abstractions under `Application/Common`, for example `Application/Common/Interfaces`.
+- Return single resources as their DTO object directly.
+- Use `ArrayResult` instead of returning raw arrays from non-paginated list endpoints.
+- Use `PaginatedQuery` for paginated request parameters and return `PaginatedResult` directly for paginated collection responses.
+- Use FluentValidation for request validation. Put validators beside feature request models in `Application`, and rely on the shared Minimal API validation endpoint filter in `CleanApiStarter.AspNetCore`. FluentValidation failures should return `422 Unprocessable Entity`.
+- Do not use DataAnnotations for application request validation.
- Use Scalar, not Swagger/Swashbuckle.
-- Use `Microsoft.AspNetCore.OpenApi` with:
- - `builder.Services.AddOpenApi();`
- - `app.MapOpenApi();`
- - `app.MapScalarApiReference();`
+- Use the .NET 10 API versioning/OpenAPI setup from the Microsoft .NET blog:
+ - `Asp.Versioning.Http` v10
+ - `Asp.Versioning.Mvc.ApiExplorer` v10
+ - `Asp.Versioning.OpenApi`
+ - `builder.Services.AddApiVersioning(...).AddApiExplorer(...).AddOpenApi();`
+ - `app.MapOpenApi().WithDocumentPerVersion();`
+ - `app.MapScalarApiReference(...)` configured from `app.DescribeApiVersions()`
- Do not add `Swashbuckle.AspNetCore`.
- Cancellation tokens are explicit:
- Do not use `CancellationToken cancellationToken = default` in service or repository contracts.
@@ -97,6 +105,8 @@ This repository is a Clean Architecture API starter template named `CleanApiStar
- `app.MapDefaultEndpoints();`
- Shared middleware such as HTTP request logging belongs in `CleanApiStarter.AspNetCore`, not duplicated inside each API project.
- Keep OpenTelemetry logs configured to include scopes, formatted messages, and parsed state values so structured message-template properties show up in Aspire.
+- Responses should include `X-Request-ID` with the current trace id, configured centrally in `CleanApiStarter.AspNetCore`.
+- Response compression should be configured centrally in `CleanApiStarter.AspNetCore` with Brotli and gzip providers.
- Use structured logging message templates instead of interpolated log strings. Prefer stable property names like `{WordId}` and `{WordCount}`.
- Register root settings once with `AddAppSettings(builder.Configuration)`, then inject `AppSettings` directly when services need configuration values.
- Do not add generic options registration helpers until the template has multiple real options sections that need them.
@@ -111,7 +121,7 @@ This repository is a Clean Architecture API starter template named `CleanApiStar
- Use built-in Minimal API mapping methods with explicit `.WithName(...)`; do not add custom `MapGet`/`MapPost` overloads that shadow framework methods.
- API versions are selected with the optional `X-Api-Version` request header. Missing versions default to v1.
- Endpoint groups should declare `MajorVersion` to match their folder, for example `V1` uses `1` and `V2` uses `2`.
-- Endpoint names must be globally unique across versions. Prefer names suffixed with the version, such as `GetAllWordsV1` and `GetAllWordsV2`.
+- Endpoint names must be globally unique across versions. Prefer names suffixed with the version, such as `GetWordsV1` and `GetWordsV2`.
- Do not reintroduce MVC controllers unless the template intentionally changes direction.
## Authentication
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 601f3d5..fa34cb3 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -10,15 +10,19 @@
-
+
+
+
+
+
-
+
diff --git a/README.md b/README.md
index 5a2c448..005a459 100644
--- a/README.md
+++ b/README.md
@@ -5,6 +5,9 @@ A Clean Architecture API starter template for .NET, Aspire, PostgreSQL, and Open
The API uses Minimal APIs with endpoint group classes. `CleanApiStarter.AspNetCore` discovers endpoint groups from the API assembly so `Program.cs` stays small while routes remain easy to find.
API versions are selected with the optional `X-Api-Version` request header. Requests without a version use v1 by default. Endpoint groups live under version folders such as `Endpoints/V1` and `Endpoints/V2`.
+OpenAPI documents are generated per API version and shown in Scalar with a version selector.
+
+Collection endpoints return an `ArrayResult` envelope when they are not paginated. Paginated endpoints accept `limit` and `offset` query parameters and return `PaginatedResult` metadata with the items. Single-resource endpoints return the resource object directly.
## Authentication
@@ -59,6 +62,8 @@ After the dashboard is running, send requests to the API through Scalar or anoth
- Logs show structured application and framework log entries. The word use cases emit fields such as `WordId`, `SynonymCount`, `WordCount`, and `UpdateSucceeded`.
- Request logs show method, path, response status code, duration, and `UserId` without logging request or response bodies. `CleanApiStarter.AspNetCore` keeps the `Microsoft.AspNetCore.HttpLogging` category at `Information` even when broader ASP.NET Core logs are filtered to `Warning`.
+- Responses include an `X-Request-ID` header containing the current OpenTelemetry trace id, which can be used to correlate API responses with Aspire traces and logs.
+- Responses support Brotli and gzip compression when clients send an `Accept-Encoding` header.
- Traces show incoming HTTP requests and PostgreSQL commands from Npgsql.
- Metrics show ASP.NET Core, HTTP client, and .NET runtime measurements.
- `/version` exposes the running application version.
@@ -72,6 +77,8 @@ Use structured logging message templates instead of interpolated log strings. Pr
Configuration classes live in `CleanApiStarter.Configuration`. Register root settings once with `AddAppSettings(builder.Configuration)`, then inject `AppSettings` directly where configuration values are needed.
+Request validation uses FluentValidation. Validators live beside their feature request models in the `Application` project and are executed by a Minimal API endpoint filter from `CleanApiStarter.AspNetCore`. FluentValidation failures return `422 Unprocessable Entity`.
+
### Run with Docker Compose Only
If you want to run PostgreSQL without Aspire:
diff --git a/src/CleanApiStarter.Api/Api.http b/src/CleanApiStarter.Api/Api.http
index 7deb1e4..66e410b 100644
--- a/src/CleanApiStarter.Api/Api.http
+++ b/src/CleanApiStarter.Api/Api.http
@@ -1,8 +1,9 @@
@CleanApiStarter_Api_HostAddress = https://localhost:7285
@AccessToken = paste-api-jwt-here
-GET {{CleanApiStarter_Api_HostAddress}}/api/words
+GET {{CleanApiStarter_Api_HostAddress}}/api/words?limit=20&offset=0
Accept: application/json
+Authorization: Bearer {{AccessToken}}
###
@@ -34,7 +35,7 @@ Authorization: Bearer {{AccessToken}}
###
-GET {{CleanApiStarter_Api_HostAddress}}/api/words
+GET {{CleanApiStarter_Api_HostAddress}}/api/words?limit=20&offset=0
Accept: application/json
X-Api-Version: 2.0
Authorization: Bearer {{AccessToken}}
diff --git a/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj b/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj
index 47c56e1..c710c1c 100644
--- a/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj
+++ b/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj
@@ -7,6 +7,8 @@
+
+
diff --git a/src/CleanApiStarter.Api/Endpoints/GoogleLoginPage.cs b/src/CleanApiStarter.Api/Endpoints/GoogleLoginPage.cs
index e57f2ec..a36d7cd 100644
--- a/src/CleanApiStarter.Api/Endpoints/GoogleLoginPage.cs
+++ b/src/CleanApiStarter.Api/Endpoints/GoogleLoginPage.cs
@@ -24,6 +24,10 @@ public static WebApplication MapGoogleLoginPage(this WebApplication app)
@@ -34,10 +38,16 @@ public static WebApplication MapGoogleLoginPage(this WebApplication app)
data-callback="handleCredentialResponse">
- API JWT
+
Sign in with Google to generate a token.