diff --git a/.claude/skills/add-endpoint/SKILL.md b/.claude/skills/add-endpoint/SKILL.md new file mode 100644 index 0000000..4c244bf --- /dev/null +++ b/.claude/skills/add-endpoint/SKILL.md @@ -0,0 +1,85 @@ +--- +name: add-endpoint +description: Add or change an API endpoint in this codebase as a vertical slice — one file per operation co-locating its request, validator, endpoint mapping, and handler. Use when adding an endpoint, operation, or whole feature to the CleanApiStarter API. +paths: src/CleanApiStarter.Api/Features/** +--- + +# Add an API endpoint (vertical slice) + +This API is a single `CleanApiStarter.Api` project organised as **vertical slices**: +one file per operation. Boundaries are enforced at build time by NsDepCop +(`src/CleanApiStarter.Api/config.nsdepcop`) — a handler must not reference an +`Infrastructure` type or the build fails. + +## Add an operation to an existing feature + +1. Create one file per operation in the feature folder, e.g. + `src/CleanApiStarter.Api/Features/Projects/ArchiveProject.cs`: + + ```csharp + namespace CleanApiStarter.Api.Features.Projects; + + public static class ArchiveProject + { + public sealed record Request(string Reason); // omit if no body + + public sealed class Validator : AbstractValidator + { + public Validator() => RuleFor(request => request.Reason).NotEmpty(); + } + + public static void Map(RouteGroupBuilder group) => + group.MapPost("/{id:guid}/archive", Handle).WithName("ArchiveProjectV1"); + + private static async Task Handle( + Guid id, + Request request, + IProjectRepository projectRepository, // an interface — never an Infrastructure type + IUser currentUser, + CancellationToken cancellationToken) + { + string userId = currentUser.RequireId(); + // orchestrate via the repository, then return a typed result + return TypedResults.NoContent(); + } + } + ``` + +2. Register the slice in the feature's endpoint group `Map` + (`Features/Projects/Projects.cs`): add `ArchiveProject.Map(groupBuilder);`. + +3. If the handler needs new data access, add a method to `IProjectRepository` + (`Features/Projects/IProjectRepository.cs`) and implement it in + `Infrastructure/Repositories/ProjectRepository.cs`. + +## Add a new feature (capability) + +1. Create `Features//`. +2. Add a thin endpoint group implementing `IEndpointGroup` — it sets `RoutePrefix`, + `MajorVersion`, and a `Map` that calls each slice's `Map`. It is discovered by + reflection; no manual registration needed. +3. Add slices as above. Put a nested resource in a sub-folder (e.g. + `Projects/Tasks/`) and a new API version in a `V2/` sub-folder. + +## Conventions + +- **Handlers depend only on interfaces** (`IProjectRepository`, `IAuthService`), + never on `Infrastructure` types. NsDepCop (`Features ✗→ Infrastructure`) breaks + the build otherwise. +- Validators are auto-registered (`AddValidatorsFromAssembly`) and run by the global + `ValidationFilter`, returning `422` on failure. Just add a nested + `Validator : AbstractValidator`. +- Return `TypedResults.*` (`Ok` / `Created` / `NoContent` / `NotFound` / + `Conflict` / `Forbid`) directly — there is no result-enum indirection. +- Map entities to DTOs with a static `From(...)` factory on the DTO + (e.g. `ProjectDto.From(project)`); for pages use `result.Map(ProjectDto.From)`. +- Group-level auth: the `Projects` group calls `RequireAuthorization()`; set + `.AllowAnonymous()` / `.RequireAuthorization()` per route when it differs. + +## Verify + +```bash +dotnet build CleanApiStarter.slnx +dotnet format CleanApiStarter.slnx --verify-no-changes +dotnet test CleanApiStarter.slnx # Docker required for the integration tests +``` diff --git a/.claude/skills/add-migration/SKILL.md b/.claude/skills/add-migration/SKILL.md new file mode 100644 index 0000000..f6dd172 --- /dev/null +++ b/.claude/skills/add-migration/SKILL.md @@ -0,0 +1,39 @@ +--- +name: add-migration +description: Add or change the PostgreSQL schema in this codebase. Use when adding or modifying a database table, column, index, or migration in CleanApiStarter, including keeping the EF Core entity configuration in sync. +paths: database/migrations/**, src/CleanApiStarter.Api/Infrastructure/Persistence/** +--- + +# Add a database change + +`database/migrations` is the schema's single source of truth. There are no EF +migrations; the EF Core entity configurations must be kept in sync with the SQL +by hand. + +## Steps + +1. Add `database/migrations/V__short_description.sql`, numbered after the + latest file. Migrations are applied in file-name order by both the Aspire + AppHost and the integration test factory, so they must be **idempotent** + (`CREATE TABLE IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, …). + +2. Update the matching EF config in + `src/CleanApiStarter.Api/Infrastructure/Persistence/Configuration/*Configuration.cs` + (an `IEntityTypeConfiguration`), and the entity in + `src/CleanApiStarter.Api/Domain/Entities/` if columns changed. + +## Conventions + +- Timestamp columns are `TIMESTAMP WITH TIME ZONE`; all values are stored in UTC + (`DateTime.UtcNow` in code). Do not use plain `TIMESTAMP`. +- Identity tables live in `V002__create_identity_tables.sql`; application tables in + `V001`. Keep new application tables in their own `V` file. +- Entities are persistence-agnostic POCOs; mapping details (column names, lengths, + relationships, delete behaviour) belong in the EF configuration, not the entity. + +## Verify + +```bash +dotnet build CleanApiStarter.slnx +dotnet test CleanApiStarter.slnx # integration tests apply the scripts to a real container +``` diff --git a/.editorconfig b/.editorconfig index f23b7d6..5755f36 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,82 +1,82 @@ -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 - +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 = lf insert_final_newline = true - -#### .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] - + +#### .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:warning csharp_style_var_for_built_in_types = false:warning @@ -84,284 +84,284 @@ 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 -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 - + +# 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/.gitignore b/.gitignore index 0e2fcbc..aa9d4f9 100644 --- a/.gitignore +++ b/.gitignore @@ -403,3 +403,6 @@ FodyWeavers.xsd # JetBrains Rider *.sln.iml .DS_Store + +# Claude Code local settings (skills under .claude/skills/ are tracked) +.claude/settings.local.json diff --git a/.template.config/template.json b/.template.config/template.json index 970f2ba..daf08a7 100644 --- a/.template.config/template.json +++ b/.template.config/template.json @@ -38,11 +38,14 @@ "**/.DS_Store", "**/bin/**", "**/obj/**", + ".claude/settings.local.json", "artifacts/**", ".github/workflows/release.yml", ".github/workflows/template.yml", "CleanApiStarter.Template.csproj", "CONTRIBUTING.md", + "adr/**", + "docs/**", "scripts/install-template.sh" ] } diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index e92b9af..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,181 +0,0 @@ -# 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.Configuration` - - `CleanApiStarter.Domain` - - `CleanApiStarter.Infrastructure` - - `CleanApiStarter.AppHost` - - `CleanApiStarter.AspNetCore` - - `CleanApiStarter.UnitTests` - -## Solution Structure - -- 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`: - - 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`. - - `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`. -- Keep domain models persistence-agnostic. Persistence mapping details belong in Infrastructure EF Core configuration. -- Use `required` for non-null required scalar properties on DTOs and domain entities. Do not use `= string.Empty` only to satisfy nullable reference type warnings. -- Keep collection properties initialized with `= []`. -- Keep EF navigation properties as `= null!` when EF is responsible for materializing them. -- Use nullable types such as `string?` or `DateTime?` only for genuinely optional values. -- 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/Projects` 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 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. - - 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 feature-specific database. -- 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_projects_and_tasks_tables.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: - - 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 - - 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();` - - `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 `{ProjectId}`, `{TaskId}`, and `{UserId}`. -- 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 group classes under version folders such as `Api/Endpoints/V1/Projects.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 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 `GetProjectsV1` and `GetProjectsV2`. -- 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`. -- Keep `PackageVersion` items sorted alphabetically by `Include`. -- Do not add package versions directly in individual `.csproj` files. - -## Template Packaging - -- This repository is also the `dotnet new` template source. -- Keep template metadata in `.template.config/template.json`. -- Keep NuGet template package metadata in `CleanApiStarter.Template.csproj`. -- Use `dotnet pack` and `dotnet nuget push` for template packaging and publishing. Do not use `nuget pack`, `nuget.exe`, or Mono. -- Use `scripts/install-template.sh` to pack and install the local template. -- Keep repo-only template packaging scripts excluded from generated template output. -- Keep CodeQL security scanning in `.github/workflows/codeql.yml`, and allow generated projects to inherit it. -- Keep release publishing triggered by GitHub Release publication with `vX.Y.Z` tags, not manual version inputs. - -## Testing - -- Use xUnit v3, AutoFixture.xUnit3, AutoFixture.AutoNSubstitute, NSubstitute, and Shouldly for unit tests. -- Reusable test helpers belong in `CleanApiStarter.Tests`. -- Application unit tests should reference `CleanApiStarter.Tests` instead of duplicating common test setup. -- API integration tests use MSTest and Testcontainers for Postgres. -- API integration tests should keep real JWT bearer authentication active. Generate test JWTs from `appsettings.Testing.json` instead of replacing authentication with a fake scheme. -- API integration tests should start a Postgres Testcontainer and apply SQL scripts from `database/migrations`. -- Keep test app settings in `appsettings.Testing.json`. -- Test methods must follow the naming pattern `UnitOfWork_StateUnderTest_ExpectedBehavior`. -- Tests must follow AAA format with explicit `// Arrange`, `// Act`, and `// Assert` sections. - -## Verification - -- After structural or package changes, run: - -```bash -dotnet restore CleanApiStarter.slnx -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/CONTRIBUTING.md b/CONTRIBUTING.md index 6681dac..109158d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,12 +1,18 @@ # Contributing to CleanApiStarter -Thanks for your interest in contributing. This repository is both a working reference application and a packaged `dotnet new` template, so changes are verified in both forms. +Thanks for your interest in contributing. This repository is both a working +reference application and a packaged `dotnet new` template, so changes are +verified in both forms. The whole application is a single +`CleanApiStarter.Api` project with boundaries enforced by an analyzer; see +[`adr/`](adr/) and [docs/architecture](docs/architecture/clean-architecture-and-vertical-slices.md) +for the reasoning. ## Prerequisites -- **.NET SDK** matching [global.json](global.json) (10.0.2xx; `rollForward: latestFeature` applies). -- **Docker** — required to run the API integration tests (Testcontainers) and local PostgreSQL. -- No IDE requirement; the solution file is [CleanApiStarter.slnx](CleanApiStarter.slnx). +- **.NET SDK** matching [global.json](global.json) (10.0.2xx; `rollForward: + latestFeature` applies). +- **Docker** — required for the integration tests (Testcontainers) and local + PostgreSQL. ## Getting started @@ -17,58 +23,86 @@ dotnet build CleanApiStarter.slnx dotnet test CleanApiStarter.slnx ``` -To run the API locally, use the Aspire AppHost (starts PostgreSQL with the schema scripts applied, plus pgAdmin): +To run the API locally via Aspire (starts PostgreSQL with the schema applied, plus +pgAdmin): ```bash dotnet run --project src/CleanApiStarter.AppHost ``` -Alternatively, start PostgreSQL with `docker compose up -d` and run `src/CleanApiStarter.Api` directly. - -See the [README](README.md) for the solution layout and layer dependency rules. The short version: dependencies point inward — `Domain` references nothing, `Application` references only `Domain`, and web/persistence concerns stay in `Api`, `AspNetCore`, and `Infrastructure`. +Alternatively, start PostgreSQL with `docker compose up -d` and run +`src/CleanApiStarter.Api` directly. ## Making changes -1. Branch from `main` (the convention here is `feature/-short-description`). +1. Branch from `main` (convention: `feature/-short-description`). 2. Keep changes focused; unrelated refactoring belongs in its own PR. 3. Open a pull request against `main`. CI must pass before review. -### What CI enforces +Working in Claude Code? This repo ships project skills in +[`.claude/skills/`](.claude/skills/) — run `/add-endpoint` or `/add-migration` +(or let Claude apply them automatically) for step-by-step, convention-correct +recipes. -The same checks run on every PR ([build.yml](.github/workflows/build.yml), [template.yml](.github/workflows/template.yml), [codeql.yml](.github/workflows/codeql.yml)): +### What CI enforces -- **Formatting** — `dotnet format CleanApiStarter.slnx --verify-no-changes`. Run `dotnet format CleanApiStarter.slnx` before pushing; style rules live in [.editorconfig](.editorconfig). -- **Build and tests** — the full suite, including the Testcontainers-based integration tests. -- **Template verification** — the template is packed, installed, and instantiated to confirm generated projects still build. +The same checks run on every PR ([build.yml](.github/workflows/build.yml), +[template.yml](.github/workflows/template.yml), +[codeql.yml](.github/workflows/codeql.yml)): + +- **Formatting** — `dotnet format CleanApiStarter.slnx --verify-no-changes`. Run + `dotnet format CleanApiStarter.slnx` before pushing; style rules live in + [.editorconfig](.editorconfig). +- **Build and tests** — the full suite, including the Testcontainers-based + integration tests. +- **Template verification** — the template is packed, installed, and instantiated + to confirm generated projects build. - **CodeQL** security scanning. ## Tests -- Unit tests: `tests/CleanApiStarter.Application.UnitTests` (xUnit v3, AutoFixture, NSubstitute, Shouldly). -- Integration tests: `tests/CleanApiStarter.Api.IntegrationTests` (xUnit v3 against a real PostgreSQL container; Docker must be running). -- Shared test infrastructure (the `ApiApplicationFactory` class fixture, AutoFixture attributes) lives in `tests/CleanApiStarter.Tests`. +- Unit tests: `tests/CleanApiStarter.UnitTests` (xUnit v3, AutoFixture, + NSubstitute, Shouldly). +- Integration tests: `tests/CleanApiStarter.IntegrationTests` (xUnit v3 + against a real PostgreSQL container; Docker must be running). +- Shared test infrastructure (the `ApiApplicationFactory` class fixture) lives in + `tests/CleanApiStarter.TestUtilities`. -New behavior needs tests. Integration tests share one database per test class, so isolate through unique user ids or resource names rather than assuming a fresh schema. +New behavior needs tests. The integration test factory is an `IClassFixture`, so +all tests in a class share one database — isolate through unique user ids or +resource names rather than assuming a fresh schema. -For a local coverage report: +For a local coverage report: `./scripts/test-coverage.sh`. -```bash -./scripts/test-coverage.sh -``` +## Boundary enforcement + +Dependency direction is enforced by [NsDepCop](https://github.com/realvizu/NsDepCop): +`src/CleanApiStarter.Api/config.nsdepcop` lists the illegal namespace directions +(for example `Domain ✗→ Infrastructure`, `Features ✗→ Infrastructure`), and +`WarningsAsErrors=NSDEPCOP01` makes a violation a build error. Update +`config.nsdepcop` when you add a layer or rule. `AspNetCoreDefaults` is kept +application-agnostic — don't introduce a dependency from it onto the Api's +settings or features. ## Database changes -The SQL scripts in `database/migrations` are the schema's source of truth; the EF Core entity configurations in `src/CleanApiStarter.Infrastructure/Persistence/Configuration` must be kept in sync with them manually. When changing either, update both. +`database/migrations` is the schema's source of truth. The EF Core entity +configurations in `src/CleanApiStarter.Api/Infrastructure/Persistence/Configuration` +must be kept in sync with the SQL by hand. When you change one, change the other. Conventions: -- Timestamp columns are `TIMESTAMP WITH TIME ZONE`; all values are stored in UTC (`DateTime.UtcNow` in code). -- Migration files are named `V__description.sql` and are applied in file-name order by both the AppHost and the integration test factory. +- Timestamp columns are `TIMESTAMP WITH TIME ZONE`; all values are stored in UTC + (`DateTime.UtcNow` in code). +- Migration files are named `V__description.sql` and applied in file-name + order by both the AppHost and the integration test factory. - Migrations must be idempotent (`CREATE TABLE IF NOT EXISTS`, etc.). ## Template changes -If your change affects the project structure, file names, or anything under [.template.config/template.json](.template.config/template.json), verify the template output locally: +If a change affects project structure, file names, or +[.template.config/template.json](.template.config/template.json), verify the +template output locally: ```bash dotnet pack CleanApiStarter.Template.csproj --configuration Release --output artifacts @@ -77,8 +111,12 @@ dotnet new clean-api-starter -n DemoProduct -o /tmp/DemoProduct dotnet build /tmp/DemoProduct ``` -Repository-only files (CI release workflows, this guide, install scripts) must be listed in the `exclude` section of `template.json` so they don't ship inside generated projects. +Repository-only files (this guide, `adr/`, `docs/`, release/template CI workflows, +install scripts) must be listed in the `exclude` section of `template.json` so they +don't ship inside generated projects. ## Reporting issues -Open a GitHub issue with reproduction steps, expected vs. actual behavior, and your environment (OS, .NET SDK version). For security vulnerabilities, please do not open a public issue — contact the maintainer directly instead. +Open a GitHub issue with reproduction steps, expected vs. actual behavior, and +your environment (OS, .NET SDK version). For security vulnerabilities, please do +not open a public issue — contact the maintainer directly instead. diff --git a/CleanApiStarter.Template.csproj b/CleanApiStarter.Template.csproj index f3497b3..d82b175 100644 --- a/CleanApiStarter.Template.csproj +++ b/CleanApiStarter.Template.csproj @@ -24,7 +24,7 @@ diff --git a/CleanApiStarter.slnx b/CleanApiStarter.slnx index acb877a..668c2dd 100644 --- a/CleanApiStarter.slnx +++ b/CleanApiStarter.slnx @@ -1,44 +1,45 @@ - - - - - - - + + + + + + + + + + + + + + - - - - - - - - - - - + + + + + - - - + + + diff --git a/Directory.Build.props b/Directory.Build.props index f2acb86..d2894e9 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,10 +1,10 @@ - - - - net10.0 - true - - enable - enable - + + + + net10.0 + true + + enable + enable + \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props index 5b5aa04..5d6efd1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -37,6 +37,7 @@ + diff --git a/README.md b/README.md index a8c68c4..72cdd28 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,43 @@ # CleanApiStarter -`CleanApiStarter` is a Clean Architecture API starter template for .NET 10, ASP.NET Core Minimal APIs, Aspire, PostgreSQL, OpenTelemetry, JWT authentication, API versioning, FluentValidation, Scalar, EF Core, and automated tests. - -The project is intentionally both a template and a working reference application. The sample domain is project management: authenticated users can create projects, manage project tasks, filter tasks by status, and complete tasks. +`CleanApiStarter` is a Clean Architecture API starter for .NET 10, ASP.NET Core +Minimal APIs, Aspire, PostgreSQL, OpenTelemetry, JWT authentication, API +versioning, FluentValidation, Scalar, EF Core, and automated tests. + +The whole application lives in a single `CleanApiStarter.Api` project, organized by +feature and layer folders, with the Clean Architecture dependency rule enforced at +compile time by [NsDepCop](https://github.com/realvizu/NsDepCop) rather than by +separate projects. The sample domain is project management: authenticated users +create projects, manage tasks, filter tasks by status, and complete them. + +## Architecture in brief + +CleanApiStarter treats architecture as three separate decisions rather than one: + +- **Feature organization** — *where do I find the code?* Related code for a + capability lives together (vertical slices), not scattered across technical + folders. +- **Dependency management** — *what may depend on what?* Clean Architecture is, at + its core, about dependency direction: business logic stays free of + infrastructure, and dependencies point inward. Here that rule is enforced at + build time by an analyzer (NsDepCop) rather than by splitting the app into many + projects. +- **Code reuse** — *is logic consistent across features?* Share what is genuinely + shared (the domain model, infrastructure); duplicate only slice-level glue + (request/response, validators, handlers) instead of coupling features through the + wrong abstraction. + +Project count is an implementation detail, not an architectural principle. For the +full reasoning see +[docs/architecture](docs/architecture/clean-architecture-and-vertical-slices.md); +for how the structure evolved, see the [decision records](adr/). ## Features -- Clean Architecture solution structure with separate `Api`, `Application`, `Domain`, `Infrastructure`, `Configuration`, and shared ASP.NET Core defaults projects. +- Single application project with feature-centric organization, plus an + application-agnostic `AspNetCoreDefaults` project and an Aspire `AppHost`. +- Compile-time boundary enforcement via NsDepCop (`config.nsdepcop`, + `WarningsAsErrors=NSDEPCOP01`): a forbidden dependency fails the build. - Minimal APIs organized by endpoint group and API version folders. - Header-based API versioning with optional `X-Api-Version`; missing versions default to v1. - Versioned OpenAPI documents shown with Scalar. @@ -23,10 +54,7 @@ The project is intentionally both a template and a working reference application - Response compression with Brotli and gzip. - FluentValidation endpoint validation returning `422 Unprocessable Entity`. - Result wrappers for collection endpoints: `PaginatedResult` and `ArrayResult`. -- xUnit v3 unit tests with AutoFixture, AutoFixture.AutoNSubstitute, NSubstitute, and Shouldly. -- xUnit v3 API integration tests with Testcontainers for PostgreSQL. -- Local coverage script that generates an HTML report with ReportGenerator. -- GitHub Actions CI with build, test, template verification, and CodeQL security scanning. +- xUnit v3 unit and Testcontainers-backed integration tests. ## Solution Layout @@ -36,565 +64,62 @@ CleanApiStarter │ └── migrations ├── scripts ├── src -│ ├── CleanApiStarter.Api -│ ├── CleanApiStarter.Application -│ ├── CleanApiStarter.Domain -│ ├── CleanApiStarter.Infrastructure +│ ├── CleanApiStarter.Api ← the whole application +│ │ ├── config.nsdepcop ← enforced dependency rules +│ │ ├── Common ← shared kernel (paged results, IUser) +│ │ ├── Domain ← entities (no outward dependencies) +│ │ ├── Features ← Auth, Projects/Tasks — one file per operation +│ │ │ (endpoint + request + validator + handler) +│ │ ├── Infrastructure ← DbContext, EF config, repositories, identity +│ │ ├── Configuration ← settings classes + options registration +│ │ ├── Services ← composition / web wiring (CurrentUser) +│ │ └── Program.cs │ └── Common -│ ├── CleanApiStarter.AppHost -│ ├── CleanApiStarter.AspNetCore -│ └── CleanApiStarter.Configuration +│ ├── CleanApiStarter.AppHost ← Aspire orchestration +│ └── CleanApiStarter.AspNetCoreDefaults ← reusable, app-agnostic web/runtime defaults └── tests - ├── CleanApiStarter.Api.IntegrationTests - ├── CleanApiStarter.Application.UnitTests - └── CleanApiStarter.Tests -``` - -Layer dependencies point inward: - -- `Domain` references nothing. -- `Application` references `Domain`. -- `Infrastructure` references `Application` and `Configuration`. -- `Api` composes `Application`, `Infrastructure`, `Configuration`, and `AspNetCore`. -- `Configuration` contains plain settings classes and options registration. -- `AspNetCore` contains reusable web/runtime defaults. -- `AppHost` contains Aspire orchestration. - -## Requirements - -- .NET SDK `10.0.203` or compatible latest feature SDK -- Docker Desktop -- Google Chrome, only for the local coverage script opening the HTML report - -The SDK is pinned in `global.json`: - -```json -{ - "sdk": { - "version": "10.0.203", - "rollForward": "latestFeature" - } -} -``` - -## Use As A Template - -Install the template package from NuGet: - -```bash -dotnet new install CleanApiStarter.Template -``` - -Create a new solution: - -```bash -dotnet new clean-api-starter -n MyProduct -``` - -The template replaces `CleanApiStarter` in solution, project, file, and namespace names. For example, `CleanApiStarter.Api` becomes `MyProduct.Api`. - -While developing the template locally, run: - -```bash -scripts/install-template.sh -``` - -That script: - -- uninstalls the previous local template package -- packs the current repo with `CleanApiStarter.Template.csproj` -- installs the generated local `.nupkg` -- deletes the temporary package from `artifacts` - -Then create a local test solution: - -```bash -dotnet new clean-api-starter -n DemoProduct -``` - -## Run With Aspire - -```bash -dotnet run --project src/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csproj -``` - -Aspire starts: - -- the API -- a PostgreSQL container using `postgres:latest` -- pgAdmin -- the Aspire dashboard - -Open the Aspire dashboard URL printed in the terminal. Use it to inspect resources, logs, traces, metrics, health, and PostgreSQL activity. - -The AppHost configures PostgreSQL like this: - -- server resource: `postgres-server` -- database resource: `postgres` -- data volume: `clean-api-starter-postgres-data` -- volume mount: `/var/lib/postgresql` -- init scripts: `database/migrations` - -PostgreSQL init scripts run only when the database volume is first created. If you change scripts and want them replayed locally, delete the old Docker volume. - -## Run With Docker Compose - -If you want only PostgreSQL without Aspire: - -```bash -docker compose up -d -dotnet run --project src/CleanApiStarter.Api/CleanApiStarter.Api.csproj -``` - -`docker-compose.yml` starts one database named `postgres` and mounts `database/migrations` into the Postgres init directory. - -## Database - -Database scripts live here: - -```text -database/migrations -├── V001__create_projects_and_tasks_tables.sql -└── V002__create_identity_tables.sql -``` - -The application uses EF Core through `ApplicationDbContext`, but local schema creation is owned by the SQL scripts. There are no DbUp calls or API startup database initializers. - -`ApplicationDbContext` lives in: - -```text -src/CleanApiStarter.Infrastructure/Persistence -``` - -EF Core mappings live in: - -```text -src/CleanApiStarter.Infrastructure/Persistence/Configuration -``` - -## API Style - -The API uses Minimal APIs. `Program.cs` stays small and delegates endpoint registration to endpoint group classes. - -Endpoint groups live under version folders: - -```text -src/CleanApiStarter.Api/Endpoints/V1 -src/CleanApiStarter.Api/Endpoints/V2 -``` - -Each endpoint group implements `IEndpointGroup` from `CleanApiStarter.AspNetCore` and is discovered through: - -```csharp -app.MapEndpoints(Assembly.GetExecutingAssembly()); -``` - -Endpoint names are globally unique across versions, for example: - -```text -GetProjectsV1 -GetProjectsV2 -``` - -## API Versioning - -API versioning uses header-based version selection: - -```http -X-Api-Version: 1.0 -``` - -The header is optional. Requests without `X-Api-Version` use v1. - -The project uses the .NET 10 API versioning/OpenAPI package setup: - -- `Asp.Versioning.Http` -- `Asp.Versioning.Mvc.ApiExplorer` -- `Asp.Versioning.OpenApi` -- `Microsoft.AspNetCore.OpenApi` - -OpenAPI documents are generated per version and displayed in Scalar. - -## Scalar - -This project uses Scalar instead of Swagger/Swashbuckle. - -When running in Development, OpenAPI and Scalar are mapped by the API: - -- versioned OpenAPI documents -- Scalar API reference with version selection - -## Authentication - -Authentication is API-first. - -The intended production flow is: - -1. A client obtains a Google ID token. -2. The client sends it to `POST /api/auth/google`. -3. The API validates the Google ID token. -4. The API creates or updates the local ASP.NET Core Identity user. -5. The API issues its own JWT. -6. Protected API calls use: - -```http -Authorization: Bearer -``` - -The API does not use cookies as the default authentication mechanism. - -### Google Client ID - -Create an OAuth client in Google Cloud Console and set the client id with user secrets: - -```bash -dotnet user-secrets set "Authentication:Google:ClientId" "" --project src/CleanApiStarter.Api/CleanApiStarter.Api.csproj -``` - -For local browser testing, open the development helper page: - -```text -https://localhost:7285/auth/google-login -``` - -The helper page signs in with Google, calls the API token endpoint, displays the API JWT, and includes a copy button. - -## Authorization - -Protected endpoint groups call `RequireAuthorization()` once at the group level, so individual project/task endpoints do not need repeated authorization declarations. - -Project authorization rules are implemented in the application service and repository queries: - -- users can only see projects they belong to -- users can only see and manage tasks inside projects they belong to -- project deletion is owner-only - -## Application Features - -The reference feature is project and task management. - -Projects: - -- create project -- list current user's projects -- get project by id -- delete project as owner - -Tasks: - -- create task under a project -- list tasks with `limit` and `offset` -- filter tasks by status -- get task by id -- update task -- complete task -- delete task - -The application project is organized by feature: - -```text -src/CleanApiStarter.Application/Features/Auth -src/CleanApiStarter.Application/Features/Projects -``` - -Cross-cutting abstractions live under: - -```text -src/CleanApiStarter.Application/Common -``` - -## Response Shapes - -Single-resource endpoints return the resource DTO directly. - -Paginated endpoints return `PaginatedResult`: - -```json -{ - "items": [], - "limit": 20, - "offset": 0, - "totalCount": 0, - "hasPreviousPage": false, - "hasNextPage": false -} -``` - -Non-paginated collection endpoints should return `ArrayResult`: - -```json -{ - "items": [], - "count": 0 -} -``` - -## Validation - -Request validation uses FluentValidation, not DataAnnotations. - -Validators live beside request models in the `Application` project. The shared Minimal API validation filter lives in `CleanApiStarter.AspNetCore`. - -Validation failures return: - -```http -422 Unprocessable Entity -``` - -## Configuration - -Configuration classes live in `CleanApiStarter.Configuration`. - -The root configuration object is: - -```csharp -AppSettings + ├── CleanApiStarter.UnitTests + ├── CleanApiStarter.IntegrationTests + └── CleanApiStarter.TestUtilities ← shared test factory + attributes ``` -It includes: - -- `ConnectionStrings.Postgres` -- `Authentication.Google.ClientId` -- `Authentication.Jwt.Issuer` -- `Authentication.Jwt.Audience` -- `Authentication.Jwt.SigningKey` -- `Authentication.Jwt.ExpirationMinutes` - -The API registers settings once: - -```csharp -builder.Services.AddAppSettings(builder.Configuration); -``` - -Services can inject `AppSettings` directly. - -## Observability - -`CleanApiStarter.AspNetCore` centralizes runtime defaults: - -- OpenTelemetry traces -- OpenTelemetry metrics -- OpenTelemetry logs -- OTLP export for Aspire -- structured log formatting -- HTTP request logging -- health checks -- service discovery -- HTTP client resilience -- problem details -- response compression -- request id header -- Kestrel `Server` header removal - -When the API runs under Aspire, inspect the dashboard for: +Dependency direction (enforced by `config.nsdepcop`): -- API request traces -- Npgsql database traces -- structured logs -- request logs with `UserId` -- runtime metrics -- resource health +- `Domain` depends on nothing else in the app — not `Features`, not `Infrastructure`. +- `Features` holds one self-contained slice per operation (endpoint + request + + validator + handler). A handler may not depend on `Infrastructure`; it talks to + abstractions (`IProjectRepository`, `IAuthService`) that `Infrastructure` + implements and DI wires up. +- `Infrastructure`, `Configuration`, `Services`, and `Program.cs` form the + composition root. +- `AspNetCoreDefaults` is an application-agnostic platform project — it holds no + knowledge of the application's settings; the Api binds configuration-dependent + options (such as JWT validation) itself. -Every response includes: +A violation — for example `using` an `Infrastructure` type from `Domain` — fails +the build with `error NSDEPCOP01`. -```http -X-Request-ID: -``` - -Use this value to correlate client responses with logs and traces. - -## Health And Version Endpoints - -The shared ASP.NET Core defaults map: - -```text -/health -/alive -/version -``` - -`/version` exposes the running application version. - -## Testing - -### Unit Tests - -Application unit tests use: - -- xUnit v3 -- AutoFixture.xUnit3 -- AutoFixture.AutoNSubstitute -- NSubstitute -- Shouldly - -Common test helpers live in: - -```text -tests/CleanApiStarter.Tests -``` - -Current reusable helpers: - -- `AutoNSubstituteDataAttribute` -- `ApiApplicationFactory` - -Test names follow: - -```text -UnitOfWork_StateUnderTest_ExpectedBehavior -``` - -Tests use AAA sections: - -```csharp -// Arrange -// Act -// Assert -``` - -Run unit tests: - -```bash -dotnet test tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj -``` - -### API Integration Tests - -API integration tests use: - -- xUnit v3 -- Microsoft.AspNetCore.Mvc.Testing -- Testcontainers for PostgreSQL -- Shouldly - -The integration test factory starts a real Postgres container, applies scripts from `database/migrations`, boots the API through `WebApplicationFactory`, and creates real JWTs from `appsettings.Testing.json`. - -The API integration tests keep JWT bearer authentication active. They do not replace authentication with a fake scheme. - -Run integration tests: - -```bash -dotnet test tests/CleanApiStarter.Api.IntegrationTests/CleanApiStarter.Api.IntegrationTests.csproj -``` - -Run all tests: - -```bash -dotnet test CleanApiStarter.slnx -``` - -## Coverage - -Generate coverage and open the HTML report in Chrome: - -```bash -scripts/test-coverage.sh -``` - -The script: - -- deletes the previous `artifacts/coverage` folder -- restores local .NET tools -- runs tests with `XPlat Code Coverage` -- generates an HTML report with ReportGenerator -- opens the report in Google Chrome - -Coverage output: - -```text -artifacts/coverage/report/index.html -``` - -## Build - -Restore and build: - -```bash -dotnet restore CleanApiStarter.slnx -dotnet build CleanApiStarter.slnx --no-restore /nr:false -v:minimal -``` - -## CI - -GitHub Actions workflows live in `.github/workflows`: - -- `build.yml` restores, builds, and tests the repository on pull requests and pushes to `main`. -- `codeql.yml` runs CodeQL static security analysis on pull requests, pushes to `main`, and weekly on Monday. -- `template.yml` packs the template, installs it locally, creates a sample solution, then restores, builds, and tests the generated output. -- `release.yml` publishes the template to NuGet when a GitHub Release is published with a tag such as `v1.0.0`. - -## Package Management - -Package versions are managed centrally in: - -```text -Directory.Packages.props -``` - -Keep package versions sorted alphabetically by `Include`. Do not add package versions directly to individual project files. - -## Coding Conventions - -- Use explicit local types. -- Use project-level `GlobalUsings.cs`. -- Keep cancellation tokens explicit. Do not use `CancellationToken cancellationToken = default` in service or repository contracts. -- Use `required` and `init` for required non-null DTO and domain properties. -- Use nullable types only for genuinely optional values. -- Use structured logging message templates instead of interpolated log strings. -- Keep repository interfaces in `Application`. -- Keep repository implementations and EF Core details in `Infrastructure`. -- Keep domain models persistence-agnostic. -- Do not reintroduce Dapper for the default data access path. -- Do not reintroduce MVC controllers unless the template intentionally changes direction. -- Do not add Swashbuckle; use Scalar. - -## Troubleshooting +## Requirements -### Aspire certificate errors +- .NET SDK `10.0.203` or a compatible latest-feature SDK (pinned in `global.json`). +- Docker Desktop (for integration tests and local PostgreSQL). -If Aspire logs an `UntrustedRoot` error or reports that no trusted development certificate exists, trust the local ASP.NET Core development certificate: +## Getting started ```bash -dotnet dev-certs https --check --trust +dotnet run --project src/CleanApiStarter.AppHost # API + PostgreSQL via Aspire +# or +docker compose up -d && dotnet run --project src/CleanApiStarter.Api ``` -If needed, reset and trust again: +Run the tests with `dotnet test CleanApiStarter.slnx`. -```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. - -### PostgreSQL init scripts did not run - -Postgres init scripts only run when the data directory is created. Delete the existing volume and start again. - -For Docker Compose: +To generate a new project from the published template: ```bash -docker compose down -v -docker compose up -d -``` - -For Aspire, delete the `clean-api-starter-postgres-data` Docker volume. - -### Postgres latest and data volumes - -This template uses `postgres:latest`. PostgreSQL 18+ expects data mounted at: - -```text -/var/lib/postgresql -``` - -Do not mount the volume at: - -```text -/var/lib/postgresql/data +dotnet new clean-api-starter -n MyApi ``` -## License +## Contributing -This project is licensed under the GNU General Public License v3.0. See `LICENSE` for details. +See [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/adr/ADR-001.md b/adr/ADR-001.md new file mode 100644 index 0000000..ed7df04 --- /dev/null +++ b/adr/ADR-001.md @@ -0,0 +1,70 @@ +# ADR-001: Fine-Grained Seven-Project Clean Architecture Structure + +- **Date:** 2026-04-26 +- **Status:** Superseded by [ADR-002](ADR-002.md) + +## Context + +The solution began as a Clean Architecture REST API starter for .NET. The +prevailing convention in the .NET ecosystem for expressing Clean Architecture is +to make each architectural layer its own compiled project. The appeal is +enforcement: the dependency rule (dependencies point inward, business logic stays +free of infrastructure) is guaranteed by the compiler, because a project cannot +reference something it has no project reference to. A layer with no reference to +the persistence layer physically cannot compile a call into it. + +Wanting the strongest possible enforcement and the cleanest separation of +concerns, the initial design maximized this split into seven projects: + +1. **Domain** — entities and core types; depends on nothing. +2. **Application** — use cases, service abstractions (e.g. repository + interfaces), DTOs, and validators; the seam between domain and infrastructure. +3. **Infrastructure** — EF Core, identity, and the implementations of the + application's abstractions. +4. **Api** — the HTTP host, endpoints, and composition root. +5. **Configuration** — settings/options classes and their registration; a neutral + leaf shared by both web and infrastructure concerns without coupling them. +6. **AspNetCore** — reusable web/runtime defaults (telemetry, problem details, + API versioning, authentication, validation, health checks). +7. **AppHost** — local-development orchestration (database, admin tooling). + +It is worth noting up front that modern orchestration tooling is itself +responsible for some of this count — a portion of the projects exist to satisfy +the local-development host rather than the application's own architecture. + +## Decision + +Adopt the seven-project structure and rely on project references for +compile-time enforcement of the dependency rule. Each layer and each +cross-cutting concern becomes a distinct project with an explicitly declared set +of references. + +## Consequences + +**Positive** + +- The dependency rule is enforced by the compiler — an illegal inward/outward + reference simply does not build. This is the strongest form of enforcement + available. +- Each project carries a single, clearly named responsibility. +- The platform and configuration projects are, in principle, reusable if the + system later grows into multiple services. + +**Negative** + +- High ceremony. Implementing a single feature touches files spread across + several projects, and day-to-day work involves constant hopping between them. +- The project count is disproportionate to a single small service. Much of the + separation — particularly extracting reusable platform code — is speculative: + it pays off only when reuse actually materializes, which for one service it + never does. +- Onboarding friction is real, and the layout invites a common misconception: + that Clean Architecture *is* this particular set of projects. It is not. The + architecture is fundamentally about the **direction of dependencies** and the + protection of business logic from external concerns — not about how many + projects exist. A large multi-project solution is not a prerequisite for good + architecture; it is one implementation of the dependency rule, and a heavy one. + +This tension — strong enforcement bought at the cost of significant ceremony, much +of it speculative — is the motivation for revisiting the structure in +[ADR-002](ADR-002.md). diff --git a/adr/ADR-002.md b/adr/ADR-002.md new file mode 100644 index 0000000..3405394 --- /dev/null +++ b/adr/ADR-002.md @@ -0,0 +1,82 @@ +# ADR-002: Maintain Two Parallel Variants — Layered and Modular + +- **Date:** 2026-06-17 +- **Status:** Superseded by [ADR-003](ADR-003.md) +- **Supersedes:** [ADR-001](ADR-001.md) + +## Context + +The seven-project structure ([ADR-001](ADR-001.md)) delivered strong, compiler- +enforced boundaries but at a cost in ceremony that felt disproportionate for a +single service. Reassessing the design surfaced three insights that reframed the +problem. + +First, what is usually discussed as one decision is really **three independent +decisions**, and conflating them causes most architecture arguments: + +- **Feature organization** — *where do I find the code?* +- **Code reuse** — *is logic consistent across features?* +- **Dependency management** — *what is allowed to depend on what?* + +Clean Architecture is an answer to dependency management. Organizing by feature +(vertical slices) is an answer to feature organization. These are orthogonal and +not opposites; a system can — and generally should — do both. + +Second, compile-time enforcement of the dependency rule **does not require +separate projects**. A namespace-dependency analyzer can inspect dependencies at +build time and fail the build when code crosses a forbidden boundary, achieving +inside a single project what project references achieve across many. + +Third, there was no single right answer for the audience. Some teams value hard, +physical boundaries and accept the ceremony; others want feature-centric +organization with minimal ceremony. Both are legitimate, and forcing one on +everyone would be wrong. + +A note on duplication informed the modular design: organizing by feature does +**not** mean duplicating everything into every feature. Shared domain concepts and +shared infrastructure (for example, a single database context and its +configuration) should be shared, not copied. Only slice-level glue — request and +response shapes, validators, and the orchestration of a single operation — is +reasonably duplicated, because coupling unrelated features through a shared +abstraction is often worse than a little repetition. + +## Decision + +Keep both structures, expressed as **two self-contained templates in one +repository**: + +- **Layered** — the multi-project structure, with boundaries enforced by project + references. For teams that want hard, physical separation. +- **Modular** — a single application project organized by feature folders, with + boundaries enforced by a namespace-dependency analyzer whose violations are + elevated to build errors. Reuses the platform and configuration projects. + +Concerns genuinely shared by both — the database schema (kept as a single source +of truth and synced into each variant), documentation, and continuous +integration — are hoisted to the repository root so the two variants cannot drift +on the parts that must agree. + +## Consequences + +**Positive** + +- Users pick the structure that fits their context without giving up enforced + dependency boundaries in either case. +- The pairing demonstrates concretely that Clean Architecture and vertical slices + coexist; they sit on different axes (dependency management vs. feature + organization). +- The duplication guidance is made explicit and consistent across both variants. + +**Negative** + +- Two trees must be maintained, with a real risk of drift. This is mitigated, but + not eliminated, by sharing the schema, docs, and CI at the root. +- Because each variant must be self-contained to function as a standalone + template, some configuration files and platform projects are necessarily + duplicated between them. +- Carrying two architectural philosophies in one repository increases the + cognitive load on maintainers and can dilute the project's message — a reader + must now understand both, and decide between them, before getting started. + +The maintenance overhead and divided focus of running two variants is what drives +the consolidation in [ADR-003](ADR-003.md). diff --git a/adr/ADR-003.md b/adr/ADR-003.md new file mode 100644 index 0000000..02b784d --- /dev/null +++ b/adr/ADR-003.md @@ -0,0 +1,90 @@ +# ADR-003: Consolidate to a Single Modular Solution at the Root (Three Projects) + +- **Date:** 2026-06-20 +- **Status:** Accepted +- **Supersedes:** [ADR-002](ADR-002.md) + +## Context + +Maintaining two parallel variants ([ADR-002](ADR-002.md)) doubled the maintenance +surface and split the project's focus. A reader had to understand both structures +before starting, and every shared change had to be applied carefully in two +places to avoid drift. The cost of running both was no longer justified by the +benefit. + +Re-examining the layered variant project by project showed that only a subset of +its projects were actually load-bearing: + +- The **application ↔ infrastructure** boundary is the one that matters — it is + what delivers dependency inversion, lets business logic be tested without a + database, and keeps persistence swappable. +- The **domain ↔ application** boundary is a *soft* rule. A leak in that + direction is far less harmful than anything reaching into infrastructure, and + many established Clean Architecture layouts keep domain and application together + in a single inner project. +- The **platform, configuration, and orchestration** projects were either + speculative reuse (extracted for a second service that does not exist) or the + unavoidable tax of local-development tooling. + +Crucially, the dependency rule can be enforced **completely within one project** +by a namespace-dependency analyzer that elevates illegal dependencies to build +errors. Separate projects are therefore not necessary to *enforce* the +architecture — they are only one way to do it, and the heaviest one. + +Two further considerations pointed the same way. Number of projects is not an +architectural principle; the architecture is the dependency direction, and that +can be preserved regardless of project count. And **cohesion and locality** — +keeping the code for one capability together — measurably reduce friction, both +for people navigating the codebase and for automated and agent-driven tooling +that benefits from consistency and co-located context. The modular variant had +already proven this end to end: feature-centric organization, boundaries enforced +at build time, and a working template. + +## Decision + +Retire the layered variant. Keep only the modular / vertical-slice solution and +promote it to the repository root. Reduce the structure to **three projects**: + +1. **Api** — the entire application: domain, features (vertical slices), + infrastructure, configuration/options, and composition. Internal boundaries + (domain ✗→ infrastructure, features ✗→ infrastructure, domain ✗→ features) are + enforced by a namespace-dependency analyzer configured to fail the build. +2. **AspNetCoreDefaults** — reusable, application-agnostic web and runtime + defaults. It deliberately holds no knowledge of the application's settings; the + composition root (Api) binds configuration-dependent options such as JWT token + validation. +3. **AppHost** — local-development orchestration. + +The dependency rule is enforced by the analyzer and configuration, not by the +number of projects. + +## Consequences + +**Positive** + +- Dramatically less ceremony. A feature lives in one place; there is no + project-hopping to implement or trace a single operation, which lowers + onboarding cost. +- A single source of truth — no second variant to keep in sync, no drift. +- The dependency guarantees that actually matter are preserved and still enforced + at build time; the architecture is unchanged, only its packaging is simpler. +- Better cohesion and locality, which helps human comprehension and consistent, + tool-assisted change. + +**Negative** + +- The "physically cannot even reference it" guarantee of separate projects is + traded for analyzer-based enforcement. This depends on the analyzer being + present and correctly configured; the risk is mitigated by committing the + configuration and treating its violations as build errors. +- Teams that specifically prefer hard, project-level boundaries no longer have a + ready-made option. This is an accepted trade-off: the analyzer provides + equivalent enforcement of the same rule. +- Folding configuration into the Api project slightly broadens its surface, but + keeps the defaults project genuinely reusable and free of app-specific types. +- References to the retired layered variant in documentation and tooling must be + updated. + +Simplicity and cohesion won because the separation that was removed was either +speculative or ceremonial, while everything that genuinely protects the +architecture — the inward dependency direction — is retained and still enforced. diff --git a/adr/README.md b/adr/README.md new file mode 100644 index 0000000..333a0c6 --- /dev/null +++ b/adr/README.md @@ -0,0 +1,20 @@ +# Architecture Decision Records + +This folder records the significant architectural decisions for this solution and +how they evolved. Each record follows a standard format (Title, Date, Status, +Context, Decision, Consequences) and is immutable once accepted — superseding +decisions are captured in new records rather than by editing old ones. + +| ADR | Title | Status | +| --- | --- | --- | +| [ADR-001](ADR-001.md) | Fine-grained seven-project Clean Architecture structure | Superseded by ADR-002 | +| [ADR-002](ADR-002.md) | Maintain two parallel variants — layered and modular | Superseded by ADR-003 | +| [ADR-003](ADR-003.md) | Consolidate to a single modular solution at the root (three projects) | Accepted | + +The progression in one line: a fine-grained seven-project split → two parallel +variants (layered + modular) → a single modular solution of three projects, where +boundaries are enforced by a namespace-dependency analyzer rather than by project +count. + +Background reading on the principles behind these decisions lives in +[`../docs/architecture`](../docs/architecture/clean-architecture-and-vertical-slices.md). diff --git a/docs/architecture/clean-architecture-and-vertical-slices.md b/docs/architecture/clean-architecture-and-vertical-slices.md new file mode 100644 index 0000000..e045bd2 --- /dev/null +++ b/docs/architecture/clean-architecture-and-vertical-slices.md @@ -0,0 +1,142 @@ +# Clean Architecture, Vertical Slices, and Duplication + +This document records the architectural reasoning behind CleanApiStarter — *why* +the structure is the way it is, not just *what* it is. The history of how the +structure evolved is captured in the [architecture decision records](../../adr/). + +## The core idea: three independent decisions + +The single most useful reframing is that "architecture" is not one choice. It is +at least three *separate, orthogonal* decisions, and most online arguments +conflate them: + +1. **Feature organization** — *"Where do I find the code?"* + Do related files for one capability live together, or are they scattered + across `Controllers/`, `Services/`, `Repositories/` folders? + +2. **Code reuse** — *"Is logic consistent across features?"* + When two features need the same rule or the same infrastructure, do they + share one implementation or each carry their own? + +3. **Dependency management** — *"What is allowed to depend on what?"* + Which directions of dependency are legal, and how is that enforced? + +Vertical Slice Architecture (VSA) is primarily an answer to **#1**. +Clean Architecture is primarily an answer to **#3**. They are not opposites and +they are not mutually exclusive — they answer different questions. You can, and +generally should, do both. + +## What Clean Architecture actually is + +Clean Architecture is **about dependency direction**, not about a project count +or a specific 2018-era folder structure. Its essential rules: + +- Protect business logic from external concerns (UI, DB, frameworks). +- Isolate infrastructure behind abstractions. +- Make dependencies point *inward* — outer layers depend on inner layers, never + the reverse. + +That is the whole of it. "Six projects" is one *implementation* of that rule, not +the rule itself. A single project can be a Clean Architecture as long as the +dependency direction is preserved and enforced. + +## What Vertical Slices actually are (and the duplication myth) + +A "vertical slice" groups everything needed for one operation together — for a +"create project" endpoint, that's the route, request/response DTOs, validation, +and the handler logic in one place — so adding or changing a feature means +working in one location instead of scrolling through parallel `Controllers/`, +`Services/`, `Models/` folders. + +The common misconception is that VSA means **"duplicate everything into every +feature folder."** It does not, and almost no real-world VSA codebase does this. +Even popular VSA templates centralize the domain model, persistence, and +infrastructure in a shared/common area; only the *endpoint-layer* types live in +the slice. In practice, "vertical slices" usually means *the UI/endpoint layer is +organized by feature* — not that business rules and persistence are copied per +feature. + +> Not everything belongs duplicated into every feature folder forever. Some +> abstractions are useful. Sharing domain concepts instead of duplicating them is +> helpful. Reusing consistent infrastructure (e.g. one DbContext) makes sense. + +## Duplication: the practical rule + +"Is duplication okay?" is a **code-reuse** decision (#2) and the answer is +*"yes for some things, no for others."* The line that has served us well: + +| Keep per-slice (duplication is fine, often preferred) | Share (duplication is a bug) | +| --- | --- | +| Request / response DTOs | Domain entities and their invariants | +| Validators | The DbContext and EF configuration | +| Handler / orchestration logic | Cross-cutting infrastructure (auth, logging) | +| Query shapes and projections | Genuinely shared business rules | + +Why per-slice duplication is *good*: forcing two features through one shared +service couples them, so a change for feature A can break feature B (the "ripple +effect"). Two near-identical handlers that evolve independently are cheaper than +one shared abstraction fighting both callers. This is the classic *"prefer +duplication over the wrong abstraction"* rule — but scoped to slice-level glue. + +Why domain/infrastructure duplication is *bad*: if a business rule (e.g. "a +task's `CompletedAt` is set when its status becomes `Done`") is copied into +multiple handlers, the copies drift out of sync. That rule belongs on the entity, +defined once. + +## How the boundaries are enforced + +Dependency rules are only real if something *fails the build* when they are +broken. CleanApiStarter enforces them with **[NsDepCop](https://github.com/realvizu/NsDepCop)**, +a Roslyn analyzer that polices *namespace* dependencies at compile time. A +declarative `config.nsdepcop` lists the illegal directions, and +`NSDEPCOP01` in the csproj turns a violation +into a build-breaking error — recreating inside a single project the guarantee +that separate projects would otherwise give through project references. The +ruleset is default-allow, then blacklists the inward-violating directions: + +```xml + + +``` + +## Two ways to enforce the same rule + +The dependency rule can be enforced two ways, and both are legitimate Clean +Architectures — they differ only in feature organization and enforcement mechanism, +not in whether they respect dependency direction: + +| | Multi-project | Single project | +| --- | --- | --- | +| Shape | A project per layer (Api, Application, Domain, Infrastructure, …) | One application project organized by feature folders | +| Feature organization | Feature folders inside layered projects | Vertical slices in `Features/` | +| Enforcement | Project references (compiler) | Namespace analyzer, build-breaking | +| Best for | Teams who want hard, physical boundaries | Smaller apps, less ceremony, feature-centric work | + +CleanApiStarter uses the single-project approach; see the +[decision records](../../adr/) for how it arrived there. + +## Where this is heading: the modular monolith + +The natural destination of "fewer projects + feature-centric + enforced +boundaries" is the **modular monolith**: top-level folders organized by *business +capability* (not by technical layer), each module owning its own slices, domain, +and persistence, exposing an explicit public contract, with a shared kernel only +for genuinely shared concepts. NsDepCop then enforces both the inward dependency +rule *and* inter-module isolation (e.g. `Modules.Projects.* ✗→ +Modules.Billing.Internal.*`). + +This gives much of the modularity and independence people reach microservices for +— autonomy, encapsulation, clear contracts — without the operational tax of a +distributed system. The single-project layout here is intentionally a stepping +stone in this direction: its `Features/` layout can grow into `Modules/` over time. + +## Summary + +- Clean Architecture = dependency direction. Vertical Slices = feature + organization. Code reuse = a third, separate decision. Don't conflate them. +- Project count is an implementation detail, not an architectural principle. +- Duplicate slice-level glue freely; never duplicate domain rules or + infrastructure. +- Enforce boundaries with something that breaks the build — a namespace analyzer + (used here) or project references. +- The modular monolith is the long-term target for serious business apps. diff --git a/scripts/install-template.sh b/scripts/install-template.sh index e41dcbe..ecfed48 100755 --- a/scripts/install-template.sh +++ b/scripts/install-template.sh @@ -2,12 +2,12 @@ set -euo pipefail root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -name="CleanApiStarter.Template.0.0.0.nupkg" -pkg="$root/artifacts/$name" +package="CleanApiStarter.Template" +pkg="$root/artifacts/$package.0.0.0.nupkg" cd "$root" -dotnet new uninstall CleanApiStarter.Template 2>/dev/null || true +dotnet new uninstall "$package" 2>/dev/null || true dotnet pack "CleanApiStarter.Template.csproj" --configuration Release --output "artifacts" dotnet new install "$pkg" --force rm -f "$pkg" diff --git a/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj b/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj index 49fc06b..269ddb0 100644 --- a/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj +++ b/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj @@ -4,12 +4,25 @@ CleanApiStarter.Api CleanApiStarter.Api clean-api-starter + NSDEPCOP01 - - - + + + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + diff --git a/src/CleanApiStarter.Application/Common/Interfaces/IUser.cs b/src/CleanApiStarter.Api/Common/Interfaces/IUser.cs similarity index 58% rename from src/CleanApiStarter.Application/Common/Interfaces/IUser.cs rename to src/CleanApiStarter.Api/Common/Interfaces/IUser.cs index 5064363..9747b92 100644 --- a/src/CleanApiStarter.Application/Common/Interfaces/IUser.cs +++ b/src/CleanApiStarter.Api/Common/Interfaces/IUser.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Application.Common.Interfaces; +namespace CleanApiStarter.Api.Common.Interfaces; public interface IUser { diff --git a/src/CleanApiStarter.Api/Common/Interfaces/UserExtensions.cs b/src/CleanApiStarter.Api/Common/Interfaces/UserExtensions.cs new file mode 100644 index 0000000..cf0892d --- /dev/null +++ b/src/CleanApiStarter.Api/Common/Interfaces/UserExtensions.cs @@ -0,0 +1,13 @@ +namespace CleanApiStarter.Api.Common.Interfaces; + +public static class UserExtensions +{ + /// + /// Returns the authenticated user's id, or throws if the request is unauthenticated. + /// Endpoints are protected by authorization, so this is a defensive guard. + /// + public static string RequireId(this IUser user) + { + return user.Id ?? throw new UnauthorizedAccessException(); + } +} diff --git a/src/CleanApiStarter.Application/Common/Models/ArrayResult.cs b/src/CleanApiStarter.Api/Common/Models/ArrayResult.cs similarity index 72% rename from src/CleanApiStarter.Application/Common/Models/ArrayResult.cs rename to src/CleanApiStarter.Api/Common/Models/ArrayResult.cs index 3e99c59..e7af6b4 100644 --- a/src/CleanApiStarter.Application/Common/Models/ArrayResult.cs +++ b/src/CleanApiStarter.Api/Common/Models/ArrayResult.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Application.Common.Models; +namespace CleanApiStarter.Api.Common.Models; public sealed class ArrayResult { diff --git a/src/CleanApiStarter.Application/Common/Models/PaginatedQuery.cs b/src/CleanApiStarter.Api/Common/Models/PaginatedQuery.cs similarity index 69% rename from src/CleanApiStarter.Application/Common/Models/PaginatedQuery.cs rename to src/CleanApiStarter.Api/Common/Models/PaginatedQuery.cs index ca80f8a..17feb6b 100644 --- a/src/CleanApiStarter.Application/Common/Models/PaginatedQuery.cs +++ b/src/CleanApiStarter.Api/Common/Models/PaginatedQuery.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Application.Common.Models; +namespace CleanApiStarter.Api.Common.Models; public sealed class PaginatedQuery { diff --git a/src/CleanApiStarter.Application/Common/Models/PaginatedQueryValidator.cs b/src/CleanApiStarter.Api/Common/Models/PaginatedQueryValidator.cs similarity index 84% rename from src/CleanApiStarter.Application/Common/Models/PaginatedQueryValidator.cs rename to src/CleanApiStarter.Api/Common/Models/PaginatedQueryValidator.cs index 8db6a25..e5516e5 100644 --- a/src/CleanApiStarter.Application/Common/Models/PaginatedQueryValidator.cs +++ b/src/CleanApiStarter.Api/Common/Models/PaginatedQueryValidator.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Application.Common.Models; +namespace CleanApiStarter.Api.Common.Models; public sealed class PaginatedQueryValidator : AbstractValidator { diff --git a/src/CleanApiStarter.Application/Common/Models/PaginatedResult.cs b/src/CleanApiStarter.Api/Common/Models/PaginatedResult.cs similarity index 92% rename from src/CleanApiStarter.Application/Common/Models/PaginatedResult.cs rename to src/CleanApiStarter.Api/Common/Models/PaginatedResult.cs index c1259db..d40ede1 100644 --- a/src/CleanApiStarter.Application/Common/Models/PaginatedResult.cs +++ b/src/CleanApiStarter.Api/Common/Models/PaginatedResult.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Application.Common.Models; +namespace CleanApiStarter.Api.Common.Models; public sealed class PaginatedResult { diff --git a/src/CleanApiStarter.Configuration/AppSettings.cs b/src/CleanApiStarter.Api/Configuration/AppSettings.cs similarity index 85% rename from src/CleanApiStarter.Configuration/AppSettings.cs rename to src/CleanApiStarter.Api/Configuration/AppSettings.cs index 19cdf16..cac0758 100644 --- a/src/CleanApiStarter.Configuration/AppSettings.cs +++ b/src/CleanApiStarter.Api/Configuration/AppSettings.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Configuration; +namespace CleanApiStarter.Api.Configuration; public sealed class AppSettings { diff --git a/src/CleanApiStarter.Api/Configuration/AuthenticationConfiguration.cs b/src/CleanApiStarter.Api/Configuration/AuthenticationConfiguration.cs new file mode 100644 index 0000000..86dad5c --- /dev/null +++ b/src/CleanApiStarter.Api/Configuration/AuthenticationConfiguration.cs @@ -0,0 +1,27 @@ +namespace CleanApiStarter.Api.Configuration; + +public static class AuthenticationConfiguration +{ + public static IServiceCollection AddJwtBearerOptions(this IServiceCollection services) + { + 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) + }; + }); + + return services; + } +} diff --git a/src/CleanApiStarter.Configuration/AuthenticationSettings.cs b/src/CleanApiStarter.Api/Configuration/AuthenticationSettings.cs similarity index 85% rename from src/CleanApiStarter.Configuration/AuthenticationSettings.cs rename to src/CleanApiStarter.Api/Configuration/AuthenticationSettings.cs index 24f76fa..19e0227 100644 --- a/src/CleanApiStarter.Configuration/AuthenticationSettings.cs +++ b/src/CleanApiStarter.Api/Configuration/AuthenticationSettings.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Configuration; +namespace CleanApiStarter.Api.Configuration; public sealed class AuthenticationSettings { diff --git a/src/CleanApiStarter.Configuration/ConnectionStringSettings.cs b/src/CleanApiStarter.Api/Configuration/ConnectionStringSettings.cs similarity index 73% rename from src/CleanApiStarter.Configuration/ConnectionStringSettings.cs rename to src/CleanApiStarter.Api/Configuration/ConnectionStringSettings.cs index 1299016..4f9b846 100644 --- a/src/CleanApiStarter.Configuration/ConnectionStringSettings.cs +++ b/src/CleanApiStarter.Api/Configuration/ConnectionStringSettings.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Configuration; +namespace CleanApiStarter.Api.Configuration; public sealed class ConnectionStringSettings { diff --git a/src/CleanApiStarter.Configuration/GoogleAuthenticationSettings.cs b/src/CleanApiStarter.Api/Configuration/GoogleAuthenticationSettings.cs similarity index 73% rename from src/CleanApiStarter.Configuration/GoogleAuthenticationSettings.cs rename to src/CleanApiStarter.Api/Configuration/GoogleAuthenticationSettings.cs index b6f8aec..a7cbf13 100644 --- a/src/CleanApiStarter.Configuration/GoogleAuthenticationSettings.cs +++ b/src/CleanApiStarter.Api/Configuration/GoogleAuthenticationSettings.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Configuration; +namespace CleanApiStarter.Api.Configuration; public sealed class GoogleAuthenticationSettings { diff --git a/src/CleanApiStarter.Configuration/JwtAuthenticationSettings.cs b/src/CleanApiStarter.Api/Configuration/JwtAuthenticationSettings.cs similarity index 88% rename from src/CleanApiStarter.Configuration/JwtAuthenticationSettings.cs rename to src/CleanApiStarter.Api/Configuration/JwtAuthenticationSettings.cs index 083c6d8..abc0bc1 100644 --- a/src/CleanApiStarter.Configuration/JwtAuthenticationSettings.cs +++ b/src/CleanApiStarter.Api/Configuration/JwtAuthenticationSettings.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Configuration; +namespace CleanApiStarter.Api.Configuration; public sealed class JwtAuthenticationSettings { diff --git a/src/CleanApiStarter.Configuration/OptionsRegistrationExtensions.cs b/src/CleanApiStarter.Api/Configuration/OptionsRegistrationExtensions.cs similarity index 91% rename from src/CleanApiStarter.Configuration/OptionsRegistrationExtensions.cs rename to src/CleanApiStarter.Api/Configuration/OptionsRegistrationExtensions.cs index 6b767c7..77b0e32 100644 --- a/src/CleanApiStarter.Configuration/OptionsRegistrationExtensions.cs +++ b/src/CleanApiStarter.Api/Configuration/OptionsRegistrationExtensions.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Configuration; +namespace CleanApiStarter.Api.Configuration; public static class OptionsRegistrationExtensions { diff --git a/src/CleanApiStarter.Domain/Entities/Project.cs b/src/CleanApiStarter.Api/Domain/Entities/Project.cs similarity index 89% rename from src/CleanApiStarter.Domain/Entities/Project.cs rename to src/CleanApiStarter.Api/Domain/Entities/Project.cs index 37555ff..e5d562e 100644 --- a/src/CleanApiStarter.Domain/Entities/Project.cs +++ b/src/CleanApiStarter.Api/Domain/Entities/Project.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Domain.Entities; +namespace CleanApiStarter.Api.Domain.Entities; public sealed class Project { diff --git a/src/CleanApiStarter.Domain/Entities/ProjectMember.cs b/src/CleanApiStarter.Api/Domain/Entities/ProjectMember.cs similarity index 83% rename from src/CleanApiStarter.Domain/Entities/ProjectMember.cs rename to src/CleanApiStarter.Api/Domain/Entities/ProjectMember.cs index c55ef9f..c3ec29e 100644 --- a/src/CleanApiStarter.Domain/Entities/ProjectMember.cs +++ b/src/CleanApiStarter.Api/Domain/Entities/ProjectMember.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Domain.Entities; +namespace CleanApiStarter.Api.Domain.Entities; public sealed class ProjectMember { diff --git a/src/CleanApiStarter.Domain/Entities/ProjectTask.cs b/src/CleanApiStarter.Api/Domain/Entities/ProjectTask.cs similarity index 91% rename from src/CleanApiStarter.Domain/Entities/ProjectTask.cs rename to src/CleanApiStarter.Api/Domain/Entities/ProjectTask.cs index c12e61c..3fc5ebb 100644 --- a/src/CleanApiStarter.Domain/Entities/ProjectTask.cs +++ b/src/CleanApiStarter.Api/Domain/Entities/ProjectTask.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Domain.Entities; +namespace CleanApiStarter.Api.Domain.Entities; public sealed class ProjectTask { diff --git a/src/CleanApiStarter.Domain/Entities/ProjectTaskStatus.cs b/src/CleanApiStarter.Api/Domain/Entities/ProjectTaskStatus.cs similarity index 63% rename from src/CleanApiStarter.Domain/Entities/ProjectTaskStatus.cs rename to src/CleanApiStarter.Api/Domain/Entities/ProjectTaskStatus.cs index f553dc5..b59bcef 100644 --- a/src/CleanApiStarter.Domain/Entities/ProjectTaskStatus.cs +++ b/src/CleanApiStarter.Api/Domain/Entities/ProjectTaskStatus.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Domain.Entities; +namespace CleanApiStarter.Api.Domain.Entities; public enum ProjectTaskStatus { diff --git a/src/CleanApiStarter.Api/Endpoints/V1/Auth.cs b/src/CleanApiStarter.Api/Endpoints/V1/Auth.cs deleted file mode 100644 index dc064cb..0000000 --- a/src/CleanApiStarter.Api/Endpoints/V1/Auth.cs +++ /dev/null @@ -1,39 +0,0 @@ -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); - } -} diff --git a/src/CleanApiStarter.Api/Endpoints/V1/Projects.cs b/src/CleanApiStarter.Api/Endpoints/V1/Projects.cs deleted file mode 100644 index c55c4a9..0000000 --- a/src/CleanApiStarter.Api/Endpoints/V1/Projects.cs +++ /dev/null @@ -1,176 +0,0 @@ -namespace CleanApiStarter.Api.Endpoints.V1; - -public sealed class Projects : IEndpointGroup -{ - public static int MajorVersion => 1; - - public static string RoutePrefix => "/api/projects"; - - public static void Map(RouteGroupBuilder groupBuilder) - { - groupBuilder.RequireAuthorization(); - - groupBuilder.MapPost("/", CreateProject) - .WithName("CreateProjectV1"); - - groupBuilder.MapGet("/", GetProjects) - .WithName("GetProjectsV1"); - - groupBuilder.MapGet("/{id:guid}", GetProject) - .WithName("GetProjectV1"); - - groupBuilder.MapDelete("/{id:guid}", DeleteProject) - .WithName("DeleteProjectV1"); - - groupBuilder.MapPost("/{projectId:guid}/tasks", CreateTask) - .WithName("CreateProjectTaskV1"); - - groupBuilder.MapGet("/{projectId:guid}/tasks", GetTasks) - .WithName("GetProjectTasksV1"); - - groupBuilder.MapGet("/{projectId:guid}/tasks/{taskId:guid}", GetTask) - .WithName("GetProjectTaskV1"); - - groupBuilder.MapPut("/{projectId:guid}/tasks/{taskId:guid}", UpdateTask) - .WithName("UpdateProjectTaskV1"); - - groupBuilder.MapPost("/{projectId:guid}/tasks/{taskId:guid}/complete", CompleteTask) - .WithName("CompleteProjectTaskV1"); - - groupBuilder.MapDelete("/{projectId:guid}/tasks/{taskId:guid}", DeleteTask) - .WithName("DeleteProjectTaskV1"); - } - - private static async Task CreateProject( - CreateProjectDto projectDto, - IProjectService projectService, - CancellationToken cancellationToken) - { - Guid id = await projectService.CreateProjectAsync(projectDto, cancellationToken); - - return Results.Created($"/api/projects/{id}", id); - } - - private static async Task GetProjects( - [AsParameters] PaginatedQuery query, - IProjectService projectService, - CancellationToken cancellationToken) - { - PaginatedResult projects = await projectService.GetProjectsAsync(query, cancellationToken); - - return Results.Ok(projects); - } - - private static async Task GetProject( - Guid id, - IProjectService projectService, - CancellationToken cancellationToken) - { - ProjectDto? project = await projectService.GetProjectAsync(id, cancellationToken); - - return project == null ? Results.NotFound() : Results.Ok(project); - } - - private static async Task DeleteProject( - Guid id, - IProjectService projectService, - CancellationToken cancellationToken) - { - DeleteProjectResult result = await projectService.DeleteProjectAsync(id, cancellationToken); - - return result switch - { - DeleteProjectResult.Deleted => Results.NoContent(), - DeleteProjectResult.Forbidden => Results.Forbid(), - _ => Results.NotFound() - }; - } - - private static async Task CreateTask( - Guid projectId, - CreateProjectTaskDto taskDto, - IProjectService projectService, - CancellationToken cancellationToken) - { - Guid? taskId = await projectService.CreateTaskAsync(projectId, taskDto, cancellationToken); - - return taskId == null - ? Results.NotFound() - : Results.Created($"/api/projects/{projectId}/tasks/{taskId}", taskId); - } - - private static async Task GetTasks( - Guid projectId, - [FromQuery] ProjectTaskStatus? status, - [AsParameters] PaginatedQuery query, - IProjectService projectService, - CancellationToken cancellationToken) - { - PaginatedResult? tasks = await projectService.GetTasksAsync( - projectId, - status, - query, - cancellationToken); - - return tasks == null ? Results.NotFound() : Results.Ok(tasks); - } - - private static async Task GetTask( - Guid projectId, - Guid taskId, - IProjectService projectService, - CancellationToken cancellationToken) - { - ProjectTaskDto? task = await projectService.GetTaskAsync(projectId, taskId, cancellationToken); - - return task == null ? Results.NotFound() : Results.Ok(task); - } - - private static async Task UpdateTask( - Guid projectId, - Guid taskId, - UpdateProjectTaskDto taskDto, - IProjectService projectService, - CancellationToken cancellationToken) - { - ProjectTaskMutationResult result = await projectService.UpdateTaskAsync( - projectId, - taskId, - taskDto, - cancellationToken); - - return result == ProjectTaskMutationResult.Success - ? Results.NoContent() - : Results.NotFound(); - } - - private static async Task CompleteTask( - Guid projectId, - Guid taskId, - IProjectService projectService, - CancellationToken cancellationToken) - { - ProjectTaskMutationResult result = await projectService.CompleteTaskAsync(projectId, taskId, cancellationToken); - - return result switch - { - ProjectTaskMutationResult.Success => Results.NoContent(), - ProjectTaskMutationResult.AlreadyCompleted => Results.Conflict(new - { - detail = "Task is already completed." - }), - _ => Results.NotFound() - }; - } - - private static async Task DeleteTask( - Guid projectId, - Guid taskId, - IProjectService projectService, - CancellationToken cancellationToken) - { - bool deleted = await projectService.DeleteTaskAsync(projectId, taskId, cancellationToken); - - return deleted ? Results.NoContent() : Results.NotFound(); - } -} diff --git a/src/CleanApiStarter.Api/Endpoints/V2/Projects.cs b/src/CleanApiStarter.Api/Endpoints/V2/Projects.cs deleted file mode 100644 index a0736b0..0000000 --- a/src/CleanApiStarter.Api/Endpoints/V2/Projects.cs +++ /dev/null @@ -1,35 +0,0 @@ -namespace CleanApiStarter.Api.Endpoints.V2; - -public sealed class Projects : IEndpointGroup -{ - public static int MajorVersion => 2; - - public static string RoutePrefix => "/api/projects"; - - public static void Map(RouteGroupBuilder groupBuilder) - { - groupBuilder.RequireAuthorization(); - - groupBuilder.MapGet("/", GetProjects) - .WithName("GetProjectsV2"); - } - - private static async Task GetProjects( - [AsParameters] PaginatedQuery query, - IProjectService projectService, - CancellationToken cancellationToken) - { - PaginatedResult projects = await projectService.GetProjectsAsync(query, cancellationToken); - - return Results.Ok(new - { - ApiVersion = "2.0", - projects.Items, - projects.Limit, - projects.Offset, - projects.TotalCount, - projects.HasPreviousPage, - projects.HasNextPage - }); - } -} diff --git a/src/CleanApiStarter.Api/Features/Auth/Auth.cs b/src/CleanApiStarter.Api/Features/Auth/Auth.cs new file mode 100644 index 0000000..e141ccb --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Auth/Auth.cs @@ -0,0 +1,14 @@ +namespace CleanApiStarter.Api.Features.Auth; + +public sealed class Auth : IEndpointGroup +{ + public static int MajorVersion => 1; + + public static string RoutePrefix => "/api/auth"; + + public static void Map(RouteGroupBuilder groupBuilder) + { + SignInWithGoogle.Map(groupBuilder); + GetCurrentUser.Map(groupBuilder); + } +} diff --git a/src/CleanApiStarter.Application/Features/Auth/AuthTokenDto.cs b/src/CleanApiStarter.Api/Features/Auth/AuthTokenDto.cs similarity index 74% rename from src/CleanApiStarter.Application/Features/Auth/AuthTokenDto.cs rename to src/CleanApiStarter.Api/Features/Auth/AuthTokenDto.cs index d115f40..182836b 100644 --- a/src/CleanApiStarter.Application/Features/Auth/AuthTokenDto.cs +++ b/src/CleanApiStarter.Api/Features/Auth/AuthTokenDto.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Application.Features.Auth; +namespace CleanApiStarter.Api.Features.Auth; public sealed class AuthTokenDto { diff --git a/src/CleanApiStarter.Application/Features/Auth/CurrentUserDto.cs b/src/CleanApiStarter.Api/Features/Auth/CurrentUserDto.cs similarity index 82% rename from src/CleanApiStarter.Application/Features/Auth/CurrentUserDto.cs rename to src/CleanApiStarter.Api/Features/Auth/CurrentUserDto.cs index f64b9d1..b6f0cd9 100644 --- a/src/CleanApiStarter.Application/Features/Auth/CurrentUserDto.cs +++ b/src/CleanApiStarter.Api/Features/Auth/CurrentUserDto.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Application.Features.Auth; +namespace CleanApiStarter.Api.Features.Auth; public sealed class CurrentUserDto { diff --git a/src/CleanApiStarter.Api/Features/Auth/GetCurrentUser.cs b/src/CleanApiStarter.Api/Features/Auth/GetCurrentUser.cs new file mode 100644 index 0000000..9e791f7 --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Auth/GetCurrentUser.cs @@ -0,0 +1,21 @@ +namespace CleanApiStarter.Api.Features.Auth; + +public static class GetCurrentUser +{ + public static void Map(RouteGroupBuilder group) + { + group.MapGet("/me", Handle) + .RequireAuthorization() + .WithName("GetCurrentUserV1"); + } + + private static async Task Handle( + ClaimsPrincipal principal, + IAuthService authService, + CancellationToken cancellationToken) + { + CurrentUserDto currentUser = await authService.GetCurrentUserAsync(principal, cancellationToken); + + return TypedResults.Ok(currentUser); + } +} diff --git a/src/CleanApiStarter.Api/Endpoints/GoogleLoginPage.cs b/src/CleanApiStarter.Api/Features/Auth/GoogleLoginPage.cs similarity index 99% rename from src/CleanApiStarter.Api/Endpoints/GoogleLoginPage.cs rename to src/CleanApiStarter.Api/Features/Auth/GoogleLoginPage.cs index 516c2a1..61d4a4b 100644 --- a/src/CleanApiStarter.Api/Endpoints/GoogleLoginPage.cs +++ b/src/CleanApiStarter.Api/Features/Auth/GoogleLoginPage.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Api.Endpoints; +namespace CleanApiStarter.Api.Features.Auth; public static class GoogleLoginPage { diff --git a/src/CleanApiStarter.Api/Features/Auth/IAuthService.cs b/src/CleanApiStarter.Api/Features/Auth/IAuthService.cs new file mode 100644 index 0000000..eb9e328 --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Auth/IAuthService.cs @@ -0,0 +1,8 @@ +namespace CleanApiStarter.Api.Features.Auth; + +public interface IAuthService +{ + Task SignInWithGoogleAsync(string idToken, CancellationToken cancellationToken); + + Task GetCurrentUserAsync(ClaimsPrincipal principal, CancellationToken cancellationToken); +} diff --git a/src/CleanApiStarter.Api/Features/Auth/SignInWithGoogle.cs b/src/CleanApiStarter.Api/Features/Auth/SignInWithGoogle.cs new file mode 100644 index 0000000..a32e68c --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Auth/SignInWithGoogle.cs @@ -0,0 +1,32 @@ +namespace CleanApiStarter.Api.Features.Auth; + +public static class SignInWithGoogle +{ + public sealed record Request(string IdToken); + + public sealed class Validator : AbstractValidator + { + public Validator() + { + RuleFor(request => request.IdToken) + .NotEmpty(); + } + } + + public static void Map(RouteGroupBuilder group) + { + group.MapPost("/google", Handle) + .AllowAnonymous() + .WithName("SignInWithGoogleV1"); + } + + private static async Task Handle( + Request request, + IAuthService authService, + CancellationToken cancellationToken) + { + AuthTokenDto token = await authService.SignInWithGoogleAsync(request.IdToken, cancellationToken); + + return TypedResults.Ok(token); + } +} diff --git a/src/CleanApiStarter.Api/Features/Projects/CreateProject.cs b/src/CleanApiStarter.Api/Features/Projects/CreateProject.cs new file mode 100644 index 0000000..ec27e08 --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Projects/CreateProject.cs @@ -0,0 +1,57 @@ +namespace CleanApiStarter.Api.Features.Projects; + +public static class CreateProject +{ + public sealed record Request(string Name, string Description); + + public sealed class Validator : AbstractValidator + { + public Validator() + { + RuleFor(request => request.Name) + .NotEmpty() + .MaximumLength(100); + + RuleFor(request => request.Description) + .MaximumLength(2_000); + } + } + + public static void Map(RouteGroupBuilder group) + { + group.MapPost("/", Handle) + .WithName("CreateProjectV1"); + } + + private static async Task Handle( + Request request, + IProjectRepository projectRepository, + IUser currentUser, + CancellationToken cancellationToken) + { + string userId = currentUser.RequireId(); + DateTime now = DateTime.UtcNow; + Guid projectId = Guid.NewGuid(); + Project project = new() + { + Id = projectId, + Name = request.Name, + Description = request.Description, + OwnerUserId = userId, + CreatedAt = now, + Members = + [ + new ProjectMember + { + ProjectId = projectId, + UserId = userId, + CreatedAt = now + } + ] + }; + + Guid createdProjectId = await projectRepository.AddProjectAsync(project, cancellationToken); + + return TypedResults.Created($"/api/projects/{createdProjectId}", createdProjectId); + } +} diff --git a/src/CleanApiStarter.Api/Features/Projects/DeleteProject.cs b/src/CleanApiStarter.Api/Features/Projects/DeleteProject.cs new file mode 100644 index 0000000..d141700 --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Projects/DeleteProject.cs @@ -0,0 +1,33 @@ +namespace CleanApiStarter.Api.Features.Projects; + +public static class DeleteProject +{ + public static void Map(RouteGroupBuilder group) + { + group.MapDelete("/{id:guid}", Handle) + .WithName("DeleteProjectV1"); + } + + private static async Task Handle( + Guid id, + IProjectRepository projectRepository, + IUser currentUser, + CancellationToken cancellationToken) + { + string userId = currentUser.RequireId(); + + if (!await projectRepository.IsProjectMemberAsync(id, userId, cancellationToken)) + { + return TypedResults.NotFound(); + } + + if (!await projectRepository.IsProjectOwnerAsync(id, userId, cancellationToken)) + { + return TypedResults.Forbid(); + } + + bool deleted = await projectRepository.DeleteProjectAsync(id, cancellationToken); + + return deleted ? TypedResults.NoContent() : TypedResults.NotFound(); + } +} diff --git a/src/CleanApiStarter.Api/Features/Projects/GetProject.cs b/src/CleanApiStarter.Api/Features/Projects/GetProject.cs new file mode 100644 index 0000000..563a00c --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Projects/GetProject.cs @@ -0,0 +1,23 @@ +namespace CleanApiStarter.Api.Features.Projects; + +public static class GetProject +{ + public static void Map(RouteGroupBuilder group) + { + group.MapGet("/{id:guid}", Handle) + .WithName("GetProjectV1"); + } + + private static async Task Handle( + Guid id, + IProjectRepository projectRepository, + IUser currentUser, + CancellationToken cancellationToken) + { + Project? project = await projectRepository.GetProjectAsync(id, currentUser.RequireId(), cancellationToken); + + return project is null + ? TypedResults.NotFound() + : TypedResults.Ok(ProjectDto.From(project)); + } +} diff --git a/src/CleanApiStarter.Api/Features/Projects/GetProjects.cs b/src/CleanApiStarter.Api/Features/Projects/GetProjects.cs new file mode 100644 index 0000000..8bbdc05 --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Projects/GetProjects.cs @@ -0,0 +1,24 @@ +namespace CleanApiStarter.Api.Features.Projects; + +public static class GetProjects +{ + public static void Map(RouteGroupBuilder group) + { + group.MapGet("/", Handle) + .WithName("GetProjectsV1"); + } + + private static async Task Handle( + [AsParameters] PaginatedQuery query, + IProjectRepository projectRepository, + IUser currentUser, + CancellationToken cancellationToken) + { + PaginatedResult projects = await projectRepository.GetProjectsAsync( + currentUser.RequireId(), + query, + cancellationToken); + + return TypedResults.Ok(projects.Map(ProjectDto.From)); + } +} diff --git a/src/CleanApiStarter.Application/Features/Projects/IProjectRepository.cs b/src/CleanApiStarter.Api/Features/Projects/IProjectRepository.cs similarity index 96% rename from src/CleanApiStarter.Application/Features/Projects/IProjectRepository.cs rename to src/CleanApiStarter.Api/Features/Projects/IProjectRepository.cs index 8f2f054..44478fe 100644 --- a/src/CleanApiStarter.Application/Features/Projects/IProjectRepository.cs +++ b/src/CleanApiStarter.Api/Features/Projects/IProjectRepository.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Application.Features.Projects; +namespace CleanApiStarter.Api.Features.Projects; public interface IProjectRepository { diff --git a/src/CleanApiStarter.Api/Features/Projects/ProjectDto.cs b/src/CleanApiStarter.Api/Features/Projects/ProjectDto.cs new file mode 100644 index 0000000..40f617f --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Projects/ProjectDto.cs @@ -0,0 +1,26 @@ +namespace CleanApiStarter.Api.Features.Projects; + +public sealed class ProjectDto +{ + public required Guid Id { get; init; } + + public required string Name { get; init; } + + public required string Description { get; init; } + + public required string OwnerUserId { get; init; } + + public required DateTime CreatedAt { get; init; } + + public static ProjectDto From(Project project) + { + return new ProjectDto + { + Id = project.Id, + Name = project.Name, + Description = project.Description, + OwnerUserId = project.OwnerUserId, + CreatedAt = project.CreatedAt + }; + } +} diff --git a/src/CleanApiStarter.Api/Features/Projects/Projects.cs b/src/CleanApiStarter.Api/Features/Projects/Projects.cs new file mode 100644 index 0000000..1ac63f5 --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Projects/Projects.cs @@ -0,0 +1,25 @@ +namespace CleanApiStarter.Api.Features.Projects; + +public sealed class Projects : IEndpointGroup +{ + public static int MajorVersion => 1; + + public static string RoutePrefix => "/api/projects"; + + public static void Map(RouteGroupBuilder groupBuilder) + { + groupBuilder.RequireAuthorization(); + + CreateProject.Map(groupBuilder); + GetProjects.Map(groupBuilder); + GetProject.Map(groupBuilder); + DeleteProject.Map(groupBuilder); + + CreateTask.Map(groupBuilder); + GetTasks.Map(groupBuilder); + GetTask.Map(groupBuilder); + UpdateTask.Map(groupBuilder); + CompleteTask.Map(groupBuilder); + DeleteTask.Map(groupBuilder); + } +} diff --git a/src/CleanApiStarter.Api/Features/Projects/Tasks/CompleteTask.cs b/src/CleanApiStarter.Api/Features/Projects/Tasks/CompleteTask.cs new file mode 100644 index 0000000..909cd93 --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Projects/Tasks/CompleteTask.cs @@ -0,0 +1,41 @@ +namespace CleanApiStarter.Api.Features.Projects.Tasks; + +public static class CompleteTask +{ + public static void Map(RouteGroupBuilder group) + { + group.MapPost("/{projectId:guid}/tasks/{taskId:guid}/complete", Handle) + .WithName("CompleteProjectTaskV1"); + } + + public static async Task Handle( + Guid projectId, + Guid taskId, + IProjectRepository projectRepository, + IUser currentUser, + CancellationToken cancellationToken) + { + ProjectTask? task = await projectRepository.GetTaskForUpdateAsync( + projectId, + taskId, + currentUser.RequireId(), + cancellationToken); + + if (task is null) + { + return TypedResults.NotFound(); + } + + if (task.Status == ProjectTaskStatus.Done) + { + return TypedResults.Conflict("Task is already completed."); + } + + task.Status = ProjectTaskStatus.Done; + task.CompletedAt = DateTime.UtcNow; + + await projectRepository.SaveChangesAsync(cancellationToken); + + return TypedResults.NoContent(); + } +} diff --git a/src/CleanApiStarter.Api/Features/Projects/Tasks/CreateTask.cs b/src/CleanApiStarter.Api/Features/Projects/Tasks/CreateTask.cs new file mode 100644 index 0000000..aeb9cda --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Projects/Tasks/CreateTask.cs @@ -0,0 +1,60 @@ +namespace CleanApiStarter.Api.Features.Projects.Tasks; + +public static class CreateTask +{ + public sealed record Request(string Title, string Description, DateTime? DueDate); + + public sealed class Validator : AbstractValidator + { + public Validator() + { + RuleFor(request => request.Title) + .NotEmpty() + .MaximumLength(150); + + RuleFor(request => request.Description) + .MaximumLength(4_000); + + RuleFor(request => request.DueDate) + .Must(dueDate => !dueDate.HasValue || dueDate.Value > DateTime.UtcNow) + .When(request => request.DueDate.HasValue) + .WithMessage("Due date must be in the future."); + } + } + + public static void Map(RouteGroupBuilder group) + { + group.MapPost("/{projectId:guid}/tasks", Handle) + .WithName("CreateProjectTaskV1"); + } + + private static async Task Handle( + Guid projectId, + Request request, + IProjectRepository projectRepository, + IUser currentUser, + CancellationToken cancellationToken) + { + string userId = currentUser.RequireId(); + + if (!await projectRepository.IsProjectMemberAsync(projectId, userId, cancellationToken)) + { + return TypedResults.NotFound(); + } + + ProjectTask task = new() + { + Id = Guid.NewGuid(), + ProjectId = projectId, + Title = request.Title, + Description = request.Description, + Status = ProjectTaskStatus.Todo, + DueDate = request.DueDate, + CreatedAt = DateTime.UtcNow + }; + + Guid taskId = await projectRepository.AddTaskAsync(task, cancellationToken); + + return TypedResults.Created($"/api/projects/{projectId}/tasks/{taskId}", taskId); + } +} diff --git a/src/CleanApiStarter.Api/Features/Projects/Tasks/DeleteTask.cs b/src/CleanApiStarter.Api/Features/Projects/Tasks/DeleteTask.cs new file mode 100644 index 0000000..2d88ec0 --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Projects/Tasks/DeleteTask.cs @@ -0,0 +1,29 @@ +namespace CleanApiStarter.Api.Features.Projects.Tasks; + +public static class DeleteTask +{ + public static void Map(RouteGroupBuilder group) + { + group.MapDelete("/{projectId:guid}/tasks/{taskId:guid}", Handle) + .WithName("DeleteProjectTaskV1"); + } + + private static async Task Handle( + Guid projectId, + Guid taskId, + IProjectRepository projectRepository, + IUser currentUser, + CancellationToken cancellationToken) + { + string userId = currentUser.RequireId(); + + if (!await projectRepository.IsProjectMemberAsync(projectId, userId, cancellationToken)) + { + return TypedResults.NotFound(); + } + + bool deleted = await projectRepository.DeleteTaskAsync(projectId, taskId, cancellationToken); + + return deleted ? TypedResults.NoContent() : TypedResults.NotFound(); + } +} diff --git a/src/CleanApiStarter.Api/Features/Projects/Tasks/GetTask.cs b/src/CleanApiStarter.Api/Features/Projects/Tasks/GetTask.cs new file mode 100644 index 0000000..a986de3 --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Projects/Tasks/GetTask.cs @@ -0,0 +1,28 @@ +namespace CleanApiStarter.Api.Features.Projects.Tasks; + +public static class GetTask +{ + public static void Map(RouteGroupBuilder group) + { + group.MapGet("/{projectId:guid}/tasks/{taskId:guid}", Handle) + .WithName("GetProjectTaskV1"); + } + + private static async Task Handle( + Guid projectId, + Guid taskId, + IProjectRepository projectRepository, + IUser currentUser, + CancellationToken cancellationToken) + { + ProjectTask? task = await projectRepository.GetTaskAsync( + projectId, + taskId, + currentUser.RequireId(), + cancellationToken); + + return task is null + ? TypedResults.NotFound() + : TypedResults.Ok(ProjectTaskDto.From(task)); + } +} diff --git a/src/CleanApiStarter.Api/Features/Projects/Tasks/GetTasks.cs b/src/CleanApiStarter.Api/Features/Projects/Tasks/GetTasks.cs new file mode 100644 index 0000000..1c49ac7 --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Projects/Tasks/GetTasks.cs @@ -0,0 +1,35 @@ +namespace CleanApiStarter.Api.Features.Projects.Tasks; + +public static class GetTasks +{ + public static void Map(RouteGroupBuilder group) + { + group.MapGet("/{projectId:guid}/tasks", Handle) + .WithName("GetProjectTasksV1"); + } + + private static async Task Handle( + Guid projectId, + [FromQuery] ProjectTaskStatus? status, + [AsParameters] PaginatedQuery query, + IProjectRepository projectRepository, + IUser currentUser, + CancellationToken cancellationToken) + { + string userId = currentUser.RequireId(); + + if (!await projectRepository.IsProjectMemberAsync(projectId, userId, cancellationToken)) + { + return TypedResults.NotFound(); + } + + PaginatedResult tasks = await projectRepository.GetTasksAsync( + projectId, + userId, + status, + query, + cancellationToken); + + return TypedResults.Ok(tasks.Map(ProjectTaskDto.From)); + } +} diff --git a/src/CleanApiStarter.Api/Features/Projects/Tasks/ProjectTaskDto.cs b/src/CleanApiStarter.Api/Features/Projects/Tasks/ProjectTaskDto.cs new file mode 100644 index 0000000..1c10d90 --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Projects/Tasks/ProjectTaskDto.cs @@ -0,0 +1,35 @@ +namespace CleanApiStarter.Api.Features.Projects.Tasks; + +public sealed class ProjectTaskDto +{ + public required Guid Id { get; init; } + + public required Guid ProjectId { get; init; } + + public required string Title { get; init; } + + public required string Description { get; init; } + + public required ProjectTaskStatus Status { get; init; } + + public DateTime? DueDate { get; init; } + + public required DateTime CreatedAt { get; init; } + + public DateTime? CompletedAt { get; init; } + + public static ProjectTaskDto From(ProjectTask task) + { + return new ProjectTaskDto + { + Id = task.Id, + ProjectId = task.ProjectId, + Title = task.Title, + Description = task.Description, + Status = task.Status, + DueDate = task.DueDate, + CreatedAt = task.CreatedAt, + CompletedAt = task.CompletedAt + }; + } +} diff --git a/src/CleanApiStarter.Api/Features/Projects/Tasks/UpdateTask.cs b/src/CleanApiStarter.Api/Features/Projects/Tasks/UpdateTask.cs new file mode 100644 index 0000000..df42df1 --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Projects/Tasks/UpdateTask.cs @@ -0,0 +1,67 @@ +namespace CleanApiStarter.Api.Features.Projects.Tasks; + +public static class UpdateTask +{ + public sealed record Request(string Title, string Description, ProjectTaskStatus Status, DateTime? DueDate); + + public sealed class Validator : AbstractValidator + { + public Validator() + { + RuleFor(request => request.Title) + .NotEmpty() + .MaximumLength(150); + + RuleFor(request => request.Description) + .MaximumLength(4_000); + + RuleFor(request => request.Status) + .IsInEnum(); + + RuleFor(request => request.DueDate) + .Must((request, dueDate) => !dueDate.HasValue + || request.Status == ProjectTaskStatus.Done + || dueDate.Value > DateTime.UtcNow) + .When(request => request.DueDate.HasValue && request.Status != ProjectTaskStatus.Done) + .WithMessage("Due date must be in the future."); + } + } + + public static void Map(RouteGroupBuilder group) + { + group.MapPut("/{projectId:guid}/tasks/{taskId:guid}", Handle) + .WithName("UpdateProjectTaskV1"); + } + + private static async Task Handle( + Guid projectId, + Guid taskId, + Request request, + IProjectRepository projectRepository, + IUser currentUser, + CancellationToken cancellationToken) + { + ProjectTask? task = await projectRepository.GetTaskForUpdateAsync( + projectId, + taskId, + currentUser.RequireId(), + cancellationToken); + + if (task is null) + { + return TypedResults.NotFound(); + } + + task.Title = request.Title; + task.Description = request.Description; + task.DueDate = request.DueDate; + task.Status = request.Status; + task.CompletedAt = request.Status == ProjectTaskStatus.Done + ? task.CompletedAt ?? DateTime.UtcNow + : null; + + await projectRepository.SaveChangesAsync(cancellationToken); + + return TypedResults.NoContent(); + } +} diff --git a/src/CleanApiStarter.Api/Features/Projects/V2/GetProjects.cs b/src/CleanApiStarter.Api/Features/Projects/V2/GetProjects.cs new file mode 100644 index 0000000..7728936 --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Projects/V2/GetProjects.cs @@ -0,0 +1,35 @@ +namespace CleanApiStarter.Api.Features.Projects.V2; + +public static class GetProjects +{ + public static void Map(RouteGroupBuilder group) + { + group.MapGet("/", Handle) + .WithName("GetProjectsV2"); + } + + private static async Task Handle( + [AsParameters] PaginatedQuery query, + IProjectRepository projectRepository, + IUser currentUser, + CancellationToken cancellationToken) + { + PaginatedResult projects = await projectRepository.GetProjectsAsync( + currentUser.RequireId(), + query, + cancellationToken); + + PaginatedResult page = projects.Map(ProjectDto.From); + + return TypedResults.Ok(new + { + ApiVersion = "2.0", + page.Items, + page.Limit, + page.Offset, + page.TotalCount, + page.HasPreviousPage, + page.HasNextPage + }); + } +} diff --git a/src/CleanApiStarter.Api/Features/Projects/V2/Projects.cs b/src/CleanApiStarter.Api/Features/Projects/V2/Projects.cs new file mode 100644 index 0000000..e751908 --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Projects/V2/Projects.cs @@ -0,0 +1,15 @@ +namespace CleanApiStarter.Api.Features.Projects.V2; + +public sealed class Projects : IEndpointGroup +{ + public static int MajorVersion => 2; + + public static string RoutePrefix => "/api/projects"; + + public static void Map(RouteGroupBuilder groupBuilder) + { + groupBuilder.RequireAuthorization(); + + GetProjects.Map(groupBuilder); + } +} diff --git a/src/CleanApiStarter.Api/GlobalUsings.cs b/src/CleanApiStarter.Api/GlobalUsings.cs index 58be6da..d32fc96 100644 --- a/src/CleanApiStarter.Api/GlobalUsings.cs +++ b/src/CleanApiStarter.Api/GlobalUsings.cs @@ -1,21 +1,41 @@ // Global using directives +global using System.ComponentModel.DataAnnotations; +global using System.IdentityModel.Tokens.Jwt; global using System.Reflection; global using System.Security.Claims; +global using System.Text; global using System.Text.Encodings.Web; -global using CleanApiStarter.Api.Endpoints; +global using CleanApiStarter.Api; +global using CleanApiStarter.Api.Common.Interfaces; +global using CleanApiStarter.Api.Common.Models; +global using CleanApiStarter.Api.Configuration; +global using CleanApiStarter.Api.Domain.Entities; +global using CleanApiStarter.Api.Features.Auth; +global using CleanApiStarter.Api.Features.Projects; +global using CleanApiStarter.Api.Features.Projects.Tasks; +global using CleanApiStarter.Api.Infrastructure; +global using CleanApiStarter.Api.Infrastructure.Identity; +global using CleanApiStarter.Api.Infrastructure.Persistence; +global using CleanApiStarter.Api.Infrastructure.Repositories; global using CleanApiStarter.Api.Services; -global using CleanApiStarter.Application; -global using CleanApiStarter.Application.Common.Interfaces; -global using CleanApiStarter.Application.Common.Models; -global using CleanApiStarter.Application.Features.Auth; -global using CleanApiStarter.Application.Features.Projects; -global using CleanApiStarter.AspNetCore; -global using CleanApiStarter.Configuration; -global using CleanApiStarter.Domain.Entities; -global using CleanApiStarter.Infrastructure; +global using CleanApiStarter.AspNetCoreDefaults; +global using FluentValidation; + +global using Google.Apis.Auth; + +global using Microsoft.AspNetCore.Authentication.JwtBearer; global using Microsoft.AspNetCore.Http; +global using Microsoft.AspNetCore.Identity; +global using Microsoft.AspNetCore.Identity.EntityFrameworkCore; global using Microsoft.AspNetCore.Mvc; global using Microsoft.AspNetCore.Routing; +global using Microsoft.EntityFrameworkCore; +global using Microsoft.EntityFrameworkCore.Metadata.Builders; +global using Microsoft.Extensions.Configuration; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Logging; +global using Microsoft.Extensions.Options; +global using Microsoft.IdentityModel.Tokens; diff --git a/src/CleanApiStarter.Api/Infrastructure/Identity/ApplicationUser.cs b/src/CleanApiStarter.Api/Infrastructure/Identity/ApplicationUser.cs new file mode 100644 index 0000000..ac813bf --- /dev/null +++ b/src/CleanApiStarter.Api/Infrastructure/Identity/ApplicationUser.cs @@ -0,0 +1,3 @@ +namespace CleanApiStarter.Api.Infrastructure.Identity; + +public sealed class ApplicationUser : IdentityUser; diff --git a/src/CleanApiStarter.Infrastructure/Identity/GoogleAuthService.cs b/src/CleanApiStarter.Api/Infrastructure/Identity/GoogleAuthService.cs similarity index 96% rename from src/CleanApiStarter.Infrastructure/Identity/GoogleAuthService.cs rename to src/CleanApiStarter.Api/Infrastructure/Identity/GoogleAuthService.cs index 367a101..9c15b69 100644 --- a/src/CleanApiStarter.Infrastructure/Identity/GoogleAuthService.cs +++ b/src/CleanApiStarter.Api/Infrastructure/Identity/GoogleAuthService.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Infrastructure.Identity; +namespace CleanApiStarter.Api.Infrastructure.Identity; public sealed class GoogleAuthService( AppSettings appSettings, @@ -8,9 +8,9 @@ public sealed class GoogleAuthService( private const string GoogleLoginProvider = "Google"; private const string DefaultRole = "User"; - public async Task SignInWithGoogleAsync(GoogleSignInDto signInDto, CancellationToken cancellationToken) + public async Task SignInWithGoogleAsync(string idToken, CancellationToken cancellationToken) { - GoogleJsonWebSignature.Payload payload = await ValidateGoogleTokenAsync(signInDto.IdToken); + GoogleJsonWebSignature.Payload payload = await ValidateGoogleTokenAsync(idToken); ApplicationUser user = await FindOrCreateUserAsync(payload, cancellationToken); IList roles = await userManager.GetRolesAsync(user); DateTimeOffset expiresAt = DateTimeOffset.UtcNow.AddMinutes(appSettings.Authentication.Jwt.ExpirationMinutes); diff --git a/src/CleanApiStarter.Infrastructure/DependencyInjection.cs b/src/CleanApiStarter.Api/Infrastructure/InfrastructureExtensions.cs similarity index 88% rename from src/CleanApiStarter.Infrastructure/DependencyInjection.cs rename to src/CleanApiStarter.Api/Infrastructure/InfrastructureExtensions.cs index 12dcd54..df3c8f9 100644 --- a/src/CleanApiStarter.Infrastructure/DependencyInjection.cs +++ b/src/CleanApiStarter.Api/Infrastructure/InfrastructureExtensions.cs @@ -1,6 +1,6 @@ -namespace CleanApiStarter.Infrastructure; +namespace CleanApiStarter.Api.Infrastructure; -public static class DependencyInjection +public static class InfrastructureExtensions { public static IServiceCollection AddInfrastructure(this IServiceCollection services) { diff --git a/src/CleanApiStarter.Infrastructure/Persistence/ApplicationDbContext.cs b/src/CleanApiStarter.Api/Infrastructure/Persistence/ApplicationDbContext.cs similarity index 90% rename from src/CleanApiStarter.Infrastructure/Persistence/ApplicationDbContext.cs rename to src/CleanApiStarter.Api/Infrastructure/Persistence/ApplicationDbContext.cs index d0e5c7e..ad3b1ca 100644 --- a/src/CleanApiStarter.Infrastructure/Persistence/ApplicationDbContext.cs +++ b/src/CleanApiStarter.Api/Infrastructure/Persistence/ApplicationDbContext.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Infrastructure.Persistence; +namespace CleanApiStarter.Api.Infrastructure.Persistence; public sealed class ApplicationDbContext(DbContextOptions options) : IdentityDbContext(options) diff --git a/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectConfiguration.cs b/src/CleanApiStarter.Api/Infrastructure/Persistence/Configuration/ProjectConfiguration.cs similarity index 92% rename from src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectConfiguration.cs rename to src/CleanApiStarter.Api/Infrastructure/Persistence/Configuration/ProjectConfiguration.cs index b919bb6..e913b36 100644 --- a/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectConfiguration.cs +++ b/src/CleanApiStarter.Api/Infrastructure/Persistence/Configuration/ProjectConfiguration.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Infrastructure.Persistence.Configuration; +namespace CleanApiStarter.Api.Infrastructure.Persistence.Configuration; public sealed class ProjectConfiguration : IEntityTypeConfiguration { diff --git a/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectMemberConfiguration.cs b/src/CleanApiStarter.Api/Infrastructure/Persistence/Configuration/ProjectMemberConfiguration.cs similarity index 92% rename from src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectMemberConfiguration.cs rename to src/CleanApiStarter.Api/Infrastructure/Persistence/Configuration/ProjectMemberConfiguration.cs index f1a3cf1..aee6cf2 100644 --- a/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectMemberConfiguration.cs +++ b/src/CleanApiStarter.Api/Infrastructure/Persistence/Configuration/ProjectMemberConfiguration.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Infrastructure.Persistence.Configuration; +namespace CleanApiStarter.Api.Infrastructure.Persistence.Configuration; public sealed class ProjectMemberConfiguration : IEntityTypeConfiguration { diff --git a/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectTaskConfiguration.cs b/src/CleanApiStarter.Api/Infrastructure/Persistence/Configuration/ProjectTaskConfiguration.cs similarity index 94% rename from src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectTaskConfiguration.cs rename to src/CleanApiStarter.Api/Infrastructure/Persistence/Configuration/ProjectTaskConfiguration.cs index aa0dfb5..1571b9c 100644 --- a/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectTaskConfiguration.cs +++ b/src/CleanApiStarter.Api/Infrastructure/Persistence/Configuration/ProjectTaskConfiguration.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Infrastructure.Persistence.Configuration; +namespace CleanApiStarter.Api.Infrastructure.Persistence.Configuration; public sealed class ProjectTaskConfiguration : IEntityTypeConfiguration { diff --git a/src/CleanApiStarter.Infrastructure/Repositories/ProjectRepository.cs b/src/CleanApiStarter.Api/Infrastructure/Repositories/ProjectRepository.cs similarity index 98% rename from src/CleanApiStarter.Infrastructure/Repositories/ProjectRepository.cs rename to src/CleanApiStarter.Api/Infrastructure/Repositories/ProjectRepository.cs index 04563a4..c955914 100644 --- a/src/CleanApiStarter.Infrastructure/Repositories/ProjectRepository.cs +++ b/src/CleanApiStarter.Api/Infrastructure/Repositories/ProjectRepository.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Infrastructure.Repositories; +namespace CleanApiStarter.Api.Infrastructure.Repositories; public sealed class ProjectRepository(ApplicationDbContext dbContext) : IProjectRepository { diff --git a/src/CleanApiStarter.Api/Program.cs b/src/CleanApiStarter.Api/Program.cs index 4637d70..c36aa07 100644 --- a/src/CleanApiStarter.Api/Program.cs +++ b/src/CleanApiStarter.Api/Program.cs @@ -3,7 +3,8 @@ builder.AddAspNetCoreDefaults(); builder.Services.AddAppSettings(builder.Configuration); -builder.Services.AddApplication(); +builder.Services.AddJwtBearerOptions(); +builder.Services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); builder.Services.AddInfrastructure(); builder.Services.AddScoped(); diff --git a/src/CleanApiStarter.Api/Properties/launchSettings.json b/src/CleanApiStarter.Api/Properties/launchSettings.json index a6d24cd..b182d09 100644 --- a/src/CleanApiStarter.Api/Properties/launchSettings.json +++ b/src/CleanApiStarter.Api/Properties/launchSettings.json @@ -1,15 +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" - } - } - } -} +{ + "$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.json b/src/CleanApiStarter.Api/appsettings.json index 4d56694..10f68b8 100644 --- a/src/CleanApiStarter.Api/appsettings.json +++ b/src/CleanApiStarter.Api/appsettings.json @@ -1,9 +1,9 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*" -} +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/CleanApiStarter.Api/config.nsdepcop b/src/CleanApiStarter.Api/config.nsdepcop new file mode 100644 index 0000000..f157c6e --- /dev/null +++ b/src/CleanApiStarter.Api/config.nsdepcop @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + diff --git a/src/CleanApiStarter.Application/CleanApiStarter.Application.csproj b/src/CleanApiStarter.Application/CleanApiStarter.Application.csproj deleted file mode 100644 index 828befe..0000000 --- a/src/CleanApiStarter.Application/CleanApiStarter.Application.csproj +++ /dev/null @@ -1,18 +0,0 @@ - - - - CleanApiStarter.Application - CleanApiStarter.Application - - - - - - - - - - - - - diff --git a/src/CleanApiStarter.Application/DependencyInjection.cs b/src/CleanApiStarter.Application/DependencyInjection.cs deleted file mode 100644 index 94651fd..0000000 --- a/src/CleanApiStarter.Application/DependencyInjection.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace CleanApiStarter.Application; - -public static class DependencyInjection -{ - public static IServiceCollection AddApplication(this IServiceCollection services) - { - services.AddValidatorsFromAssembly(typeof(DependencyInjection).Assembly); - services.AddScoped(); - - return services; - } -} diff --git a/src/CleanApiStarter.Application/Features/Auth/GoogleSignInDto.cs b/src/CleanApiStarter.Application/Features/Auth/GoogleSignInDto.cs deleted file mode 100644 index b1ebed4..0000000 --- a/src/CleanApiStarter.Application/Features/Auth/GoogleSignInDto.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace CleanApiStarter.Application.Features.Auth; - -public sealed class GoogleSignInDto -{ - public required string IdToken { get; init; } -} diff --git a/src/CleanApiStarter.Application/Features/Auth/GoogleSignInDtoValidator.cs b/src/CleanApiStarter.Application/Features/Auth/GoogleSignInDtoValidator.cs deleted file mode 100644 index 7ef17c3..0000000 --- a/src/CleanApiStarter.Application/Features/Auth/GoogleSignInDtoValidator.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace CleanApiStarter.Application.Features.Auth; - -public sealed class GoogleSignInDtoValidator : AbstractValidator -{ - public GoogleSignInDtoValidator() - { - RuleFor(signIn => signIn.IdToken) - .NotEmpty(); - } -} diff --git a/src/CleanApiStarter.Application/Features/Auth/IAuthService.cs b/src/CleanApiStarter.Application/Features/Auth/IAuthService.cs deleted file mode 100644 index 1e1fc6a..0000000 --- a/src/CleanApiStarter.Application/Features/Auth/IAuthService.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace CleanApiStarter.Application.Features.Auth; - -public interface IAuthService -{ - Task SignInWithGoogleAsync(GoogleSignInDto signInDto, CancellationToken cancellationToken); - - Task GetCurrentUserAsync(ClaimsPrincipal principal, CancellationToken cancellationToken); -} diff --git a/src/CleanApiStarter.Application/Features/Projects/CreateProjectDtoValidator.cs b/src/CleanApiStarter.Application/Features/Projects/CreateProjectDtoValidator.cs deleted file mode 100644 index fb524b1..0000000 --- a/src/CleanApiStarter.Application/Features/Projects/CreateProjectDtoValidator.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace CleanApiStarter.Application.Features.Projects; - -public sealed class CreateProjectDtoValidator : AbstractValidator -{ - public CreateProjectDtoValidator() - { - RuleFor(project => project.Name) - .NotEmpty() - .MaximumLength(100); - - RuleFor(project => project.Description) - .MaximumLength(2_000); - } -} diff --git a/src/CleanApiStarter.Application/Features/Projects/CreateProjectTaskDtoValidator.cs b/src/CleanApiStarter.Application/Features/Projects/CreateProjectTaskDtoValidator.cs deleted file mode 100644 index c06a35f..0000000 --- a/src/CleanApiStarter.Application/Features/Projects/CreateProjectTaskDtoValidator.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace CleanApiStarter.Application.Features.Projects; - -public sealed class CreateProjectTaskDtoValidator : AbstractValidator -{ - public CreateProjectTaskDtoValidator() - { - RuleFor(task => task.Title) - .NotEmpty() - .MaximumLength(150); - - RuleFor(task => task.Description) - .MaximumLength(4_000); - - RuleFor(task => task.DueDate) - .Must(dueDate => !dueDate.HasValue || dueDate.Value > DateTime.UtcNow) - .When(task => task.DueDate.HasValue) - .WithMessage("Due date must be in the future."); - } -} diff --git a/src/CleanApiStarter.Application/Features/Projects/IProjectService.cs b/src/CleanApiStarter.Application/Features/Projects/IProjectService.cs deleted file mode 100644 index ff0a3c6..0000000 --- a/src/CleanApiStarter.Application/Features/Projects/IProjectService.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace CleanApiStarter.Application.Features.Projects; - -public interface IProjectService -{ - Task CreateProjectAsync(CreateProjectDto projectDto, CancellationToken cancellationToken); - - Task GetProjectAsync(Guid id, CancellationToken cancellationToken); - - Task> GetProjectsAsync(PaginatedQuery query, CancellationToken cancellationToken); - - Task DeleteProjectAsync(Guid id, CancellationToken cancellationToken); - - Task CreateTaskAsync(Guid projectId, CreateProjectTaskDto taskDto, CancellationToken cancellationToken); - - Task GetTaskAsync(Guid projectId, Guid taskId, CancellationToken cancellationToken); - - Task?> GetTasksAsync( - Guid projectId, - ProjectTaskStatus? status, - PaginatedQuery query, - CancellationToken cancellationToken); - - Task UpdateTaskAsync( - Guid projectId, - Guid taskId, - UpdateProjectTaskDto taskDto, - CancellationToken cancellationToken); - - Task CompleteTaskAsync(Guid projectId, Guid taskId, CancellationToken cancellationToken); - - Task DeleteTaskAsync(Guid projectId, Guid taskId, CancellationToken cancellationToken); -} diff --git a/src/CleanApiStarter.Application/Features/Projects/ProjectDto.cs b/src/CleanApiStarter.Application/Features/Projects/ProjectDto.cs deleted file mode 100644 index c8d8356..0000000 --- a/src/CleanApiStarter.Application/Features/Projects/ProjectDto.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace CleanApiStarter.Application.Features.Projects; - -public sealed class ProjectDto -{ - public required Guid Id { get; init; } - - public required string Name { get; init; } - - public required string Description { get; init; } - - public required string OwnerUserId { get; init; } - - public required DateTime CreatedAt { get; init; } -} - -public sealed class CreateProjectDto -{ - public required string Name { get; init; } - - public required string Description { get; init; } -} diff --git a/src/CleanApiStarter.Application/Features/Projects/ProjectOperationResults.cs b/src/CleanApiStarter.Application/Features/Projects/ProjectOperationResults.cs deleted file mode 100644 index 2f4dfae..0000000 --- a/src/CleanApiStarter.Application/Features/Projects/ProjectOperationResults.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace CleanApiStarter.Application.Features.Projects; - -public enum DeleteProjectResult -{ - Deleted, - NotFound, - Forbidden -} - -public enum ProjectTaskMutationResult -{ - Success, - NotFound, - AlreadyCompleted -} diff --git a/src/CleanApiStarter.Application/Features/Projects/ProjectService.cs b/src/CleanApiStarter.Application/Features/Projects/ProjectService.cs deleted file mode 100644 index 9d162bd..0000000 --- a/src/CleanApiStarter.Application/Features/Projects/ProjectService.cs +++ /dev/null @@ -1,248 +0,0 @@ -namespace CleanApiStarter.Application.Features.Projects; - -public sealed class ProjectService( - IProjectRepository projectRepository, - IUser currentUser, - ILogger logger) : IProjectService -{ - public async Task CreateProjectAsync(CreateProjectDto projectDto, CancellationToken cancellationToken) - { - string userId = GetCurrentUserId(); - DateTime now = DateTime.UtcNow; - Guid projectId = Guid.NewGuid(); - Project project = new() - { - Id = projectId, - Name = projectDto.Name, - Description = projectDto.Description, - OwnerUserId = userId, - CreatedAt = now, - Members = - [ - new ProjectMember - { - ProjectId = projectId, - UserId = userId, - CreatedAt = now - } - ] - }; - - Guid createdProjectId = await projectRepository.AddProjectAsync(project, cancellationToken); - - logger.LogInformation("Created project {ProjectId} for user {UserId}", createdProjectId, userId); - - return createdProjectId; - } - - public async Task GetProjectAsync(Guid id, CancellationToken cancellationToken) - { - string userId = GetCurrentUserId(); - Project? project = await projectRepository.GetProjectAsync(id, userId, cancellationToken); - - return project == null ? null : MapProject(project); - } - - public async Task> GetProjectsAsync( - PaginatedQuery query, - CancellationToken cancellationToken) - { - string userId = GetCurrentUserId(); - PaginatedResult projects = await projectRepository.GetProjectsAsync(userId, query, cancellationToken); - - logger.LogInformation( - "Retrieved {ProjectCount} projects for user {UserId} with limit {Limit} and offset {Offset}", - projects.Items.Count, - userId, - projects.Limit, - projects.Offset); - - return projects.Map(MapProject); - } - - public async Task DeleteProjectAsync(Guid id, CancellationToken cancellationToken) - { - string userId = GetCurrentUserId(); - bool isMember = await projectRepository.IsProjectMemberAsync(id, userId, cancellationToken); - - if (!isMember) - { - return DeleteProjectResult.NotFound; - } - - bool isOwner = await projectRepository.IsProjectOwnerAsync(id, userId, cancellationToken); - - if (!isOwner) - { - return DeleteProjectResult.Forbidden; - } - - bool deleted = await projectRepository.DeleteProjectAsync(id, cancellationToken); - - return deleted ? DeleteProjectResult.Deleted : DeleteProjectResult.NotFound; - } - - public async Task CreateTaskAsync( - Guid projectId, - CreateProjectTaskDto taskDto, - CancellationToken cancellationToken) - { - string userId = GetCurrentUserId(); - bool isMember = await projectRepository.IsProjectMemberAsync(projectId, userId, cancellationToken); - - if (!isMember) - { - return null; - } - - ProjectTask task = new() - { - Id = Guid.NewGuid(), - ProjectId = projectId, - Title = taskDto.Title, - Description = taskDto.Description, - Status = ProjectTaskStatus.Todo, - DueDate = taskDto.DueDate, - CreatedAt = DateTime.UtcNow - }; - - Guid taskId = await projectRepository.AddTaskAsync(task, cancellationToken); - - logger.LogInformation("Created task {TaskId} in project {ProjectId}", taskId, projectId); - - return taskId; - } - - public async Task GetTaskAsync( - Guid projectId, - Guid taskId, - CancellationToken cancellationToken) - { - string userId = GetCurrentUserId(); - ProjectTask? task = await projectRepository.GetTaskAsync(projectId, taskId, userId, cancellationToken); - - return task == null ? null : MapTask(task); - } - - public async Task?> GetTasksAsync( - Guid projectId, - ProjectTaskStatus? status, - PaginatedQuery query, - CancellationToken cancellationToken) - { - string userId = GetCurrentUserId(); - bool isMember = await projectRepository.IsProjectMemberAsync(projectId, userId, cancellationToken); - - if (!isMember) - { - return null; - } - - PaginatedResult tasks = await projectRepository.GetTasksAsync( - projectId, - userId, - status, - query, - cancellationToken); - - return tasks.Map(MapTask); - } - - public async Task UpdateTaskAsync( - Guid projectId, - Guid taskId, - UpdateProjectTaskDto taskDto, - CancellationToken cancellationToken) - { - string userId = GetCurrentUserId(); - ProjectTask? task = await projectRepository.GetTaskForUpdateAsync(projectId, taskId, userId, cancellationToken); - - if (task == null) - { - return ProjectTaskMutationResult.NotFound; - } - - task.Title = taskDto.Title; - task.Description = taskDto.Description; - task.DueDate = taskDto.DueDate; - task.Status = taskDto.Status; - task.CompletedAt = taskDto.Status == ProjectTaskStatus.Done - ? task.CompletedAt ?? DateTime.UtcNow - : null; - - await projectRepository.SaveChangesAsync(cancellationToken); - - return ProjectTaskMutationResult.Success; - } - - public async Task CompleteTaskAsync( - Guid projectId, - Guid taskId, - CancellationToken cancellationToken) - { - string userId = GetCurrentUserId(); - ProjectTask? task = await projectRepository.GetTaskForUpdateAsync(projectId, taskId, userId, cancellationToken); - - if (task == null) - { - return ProjectTaskMutationResult.NotFound; - } - - if (task.Status == ProjectTaskStatus.Done) - { - return ProjectTaskMutationResult.AlreadyCompleted; - } - - task.Status = ProjectTaskStatus.Done; - task.CompletedAt = DateTime.UtcNow; - - await projectRepository.SaveChangesAsync(cancellationToken); - - return ProjectTaskMutationResult.Success; - } - - public async Task DeleteTaskAsync(Guid projectId, Guid taskId, CancellationToken cancellationToken) - { - string userId = GetCurrentUserId(); - bool isMember = await projectRepository.IsProjectMemberAsync(projectId, userId, cancellationToken); - - if (!isMember) - { - return false; - } - - return await projectRepository.DeleteTaskAsync(projectId, taskId, cancellationToken); - } - - private string GetCurrentUserId() - { - return currentUser.Id ?? throw new UnauthorizedAccessException(); - } - - private static ProjectDto MapProject(Project project) - { - return new ProjectDto - { - Id = project.Id, - Name = project.Name, - Description = project.Description, - OwnerUserId = project.OwnerUserId, - CreatedAt = project.CreatedAt - }; - } - - private static ProjectTaskDto MapTask(ProjectTask task) - { - return new ProjectTaskDto - { - Id = task.Id, - ProjectId = task.ProjectId, - Title = task.Title, - Description = task.Description, - Status = task.Status, - DueDate = task.DueDate, - CreatedAt = task.CreatedAt, - CompletedAt = task.CompletedAt - }; - } -} diff --git a/src/CleanApiStarter.Application/Features/Projects/ProjectTaskDto.cs b/src/CleanApiStarter.Application/Features/Projects/ProjectTaskDto.cs deleted file mode 100644 index 03f71fa..0000000 --- a/src/CleanApiStarter.Application/Features/Projects/ProjectTaskDto.cs +++ /dev/null @@ -1,40 +0,0 @@ -namespace CleanApiStarter.Application.Features.Projects; - -public sealed class ProjectTaskDto -{ - public required Guid Id { get; init; } - - public required Guid ProjectId { get; init; } - - public required string Title { get; init; } - - public required string Description { get; init; } - - public required ProjectTaskStatus Status { get; init; } - - public DateTime? DueDate { get; init; } - - public required DateTime CreatedAt { get; init; } - - public DateTime? CompletedAt { get; init; } -} - -public sealed class CreateProjectTaskDto -{ - public required string Title { get; init; } - - public required string Description { get; init; } - - public DateTime? DueDate { get; init; } -} - -public sealed class UpdateProjectTaskDto -{ - public required string Title { get; init; } - - public required string Description { get; init; } - - public required ProjectTaskStatus Status { get; init; } - - public DateTime? DueDate { get; init; } -} diff --git a/src/CleanApiStarter.Application/Features/Projects/UpdateProjectTaskDtoValidator.cs b/src/CleanApiStarter.Application/Features/Projects/UpdateProjectTaskDtoValidator.cs deleted file mode 100644 index 0227274..0000000 --- a/src/CleanApiStarter.Application/Features/Projects/UpdateProjectTaskDtoValidator.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace CleanApiStarter.Application.Features.Projects; - -public sealed class UpdateProjectTaskDtoValidator : AbstractValidator -{ - public UpdateProjectTaskDtoValidator() - { - RuleFor(task => task.Title) - .NotEmpty() - .MaximumLength(150); - - RuleFor(task => task.Description) - .MaximumLength(4_000); - - RuleFor(task => task.Status) - .IsInEnum(); - - RuleFor(task => task.DueDate) - .Must((task, dueDate) => !dueDate.HasValue - || task.Status == ProjectTaskStatus.Done - || dueDate.Value > DateTime.UtcNow) - .When(task => task.DueDate.HasValue && task.Status != ProjectTaskStatus.Done) - .WithMessage("Due date must be in the future."); - } -} diff --git a/src/CleanApiStarter.Application/GlobalUsings.cs b/src/CleanApiStarter.Application/GlobalUsings.cs deleted file mode 100644 index a886fb3..0000000 --- a/src/CleanApiStarter.Application/GlobalUsings.cs +++ /dev/null @@ -1,11 +0,0 @@ -global using System.Security.Claims; - -global using CleanApiStarter.Application.Common.Interfaces; -global using CleanApiStarter.Application.Common.Models; -global using CleanApiStarter.Application.Features.Projects; -global using CleanApiStarter.Domain.Entities; - -global using FluentValidation; - -global using Microsoft.Extensions.DependencyInjection; -global using Microsoft.Extensions.Logging; diff --git a/src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs b/src/CleanApiStarter.AspNetCoreDefaults/AspNetCoreDefaultServices.cs similarity index 77% rename from src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs rename to src/CleanApiStarter.AspNetCoreDefaults/AspNetCoreDefaultServices.cs index 2e7e5bc..12a2a39 100644 --- a/src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs +++ b/src/CleanApiStarter.AspNetCoreDefaults/AspNetCoreDefaultServices.cs @@ -1,8 +1,8 @@ using Microsoft.AspNetCore.Authorization; -namespace CleanApiStarter.AspNetCore; +namespace CleanApiStarter.AspNetCoreDefaults; -public static partial class Extensions +public static partial class AspNetCoreDefaultsExtensions { private static void AddSecurityDefaults(this IHostApplicationBuilder builder) { @@ -46,23 +46,9 @@ private static void AddAuthenticationDefaults(this WebApplicationBuilder builder .Build(); }); - 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) - }; - }); + // The JWT bearer token validation parameters are bound from application + // settings by the composition root (the Api project), so these defaults + // stay free of any app-specific configuration types. } private static void AddHttpLoggingDefaults(this IHostApplicationBuilder builder) diff --git a/src/CleanApiStarter.AspNetCore/Extensions.cs b/src/CleanApiStarter.AspNetCoreDefaults/AspNetCoreDefaultsExtensions.cs similarity index 93% rename from src/CleanApiStarter.AspNetCore/Extensions.cs rename to src/CleanApiStarter.AspNetCoreDefaults/AspNetCoreDefaultsExtensions.cs index ca8c5b6..144f138 100644 --- a/src/CleanApiStarter.AspNetCore/Extensions.cs +++ b/src/CleanApiStarter.AspNetCoreDefaults/AspNetCoreDefaultsExtensions.cs @@ -1,6 +1,6 @@ -namespace CleanApiStarter.AspNetCore; +namespace CleanApiStarter.AspNetCoreDefaults; -public static partial class Extensions +public static partial class AspNetCoreDefaultsExtensions { public static WebApplicationBuilder AddAspNetCoreDefaults(this WebApplicationBuilder builder) { @@ -46,7 +46,7 @@ public static WebApplication MapDefaultEndpoints(this WebApplication app) { app.MapGet("/version", () => { - Assembly assembly = Assembly.GetEntryAssembly() ?? typeof(Extensions).Assembly; + Assembly assembly = Assembly.GetEntryAssembly() ?? typeof(AspNetCoreDefaultsExtensions).Assembly; AssemblyInformationalVersionAttribute? informationalVersion = assembly .GetCustomAttribute(); diff --git a/src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj b/src/CleanApiStarter.AspNetCoreDefaults/CleanApiStarter.AspNetCoreDefaults.csproj similarity index 82% rename from src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj rename to src/CleanApiStarter.AspNetCoreDefaults/CleanApiStarter.AspNetCoreDefaults.csproj index a27ae2d..5006b81 100644 --- a/src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj +++ b/src/CleanApiStarter.AspNetCoreDefaults/CleanApiStarter.AspNetCoreDefaults.csproj @@ -1,14 +1,10 @@ - CleanApiStarter.AspNetCore - CleanApiStarter.AspNetCore + CleanApiStarter.AspNetCoreDefaults + CleanApiStarter.AspNetCoreDefaults - - - - diff --git a/src/CleanApiStarter.AspNetCore/GlobalUsings.cs b/src/CleanApiStarter.AspNetCoreDefaults/GlobalUsings.cs similarity index 96% rename from src/CleanApiStarter.AspNetCore/GlobalUsings.cs rename to src/CleanApiStarter.AspNetCoreDefaults/GlobalUsings.cs index 1a87110..c9242a1 100644 --- a/src/CleanApiStarter.AspNetCore/GlobalUsings.cs +++ b/src/CleanApiStarter.AspNetCoreDefaults/GlobalUsings.cs @@ -9,8 +9,6 @@ global using Asp.Versioning.ApiExplorer; global using Asp.Versioning.Builder; -global using CleanApiStarter.Configuration; - global using FluentValidation; global using FluentValidation.Results; diff --git a/src/CleanApiStarter.AspNetCore/IEndpointGroup.cs b/src/CleanApiStarter.AspNetCoreDefaults/IEndpointGroup.cs similarity index 80% rename from src/CleanApiStarter.AspNetCore/IEndpointGroup.cs rename to src/CleanApiStarter.AspNetCoreDefaults/IEndpointGroup.cs index 66a2135..47296b2 100644 --- a/src/CleanApiStarter.AspNetCore/IEndpointGroup.cs +++ b/src/CleanApiStarter.AspNetCoreDefaults/IEndpointGroup.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.AspNetCore; +namespace CleanApiStarter.AspNetCoreDefaults; public interface IEndpointGroup { diff --git a/src/CleanApiStarter.AspNetCore/OpenApiDocumentationExtensions.cs b/src/CleanApiStarter.AspNetCoreDefaults/OpenApiDocumentationExtensions.cs similarity index 95% rename from src/CleanApiStarter.AspNetCore/OpenApiDocumentationExtensions.cs rename to src/CleanApiStarter.AspNetCoreDefaults/OpenApiDocumentationExtensions.cs index fba74c4..0314cb5 100644 --- a/src/CleanApiStarter.AspNetCore/OpenApiDocumentationExtensions.cs +++ b/src/CleanApiStarter.AspNetCoreDefaults/OpenApiDocumentationExtensions.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.AspNetCore; +namespace CleanApiStarter.AspNetCoreDefaults; public static class OpenApiDocumentationExtensions { diff --git a/src/CleanApiStarter.AspNetCore/OpenTelemetryDefaults.cs b/src/CleanApiStarter.AspNetCoreDefaults/OpenTelemetryDefaults.cs similarity index 93% rename from src/CleanApiStarter.AspNetCore/OpenTelemetryDefaults.cs rename to src/CleanApiStarter.AspNetCoreDefaults/OpenTelemetryDefaults.cs index 70733a2..21c557c 100644 --- a/src/CleanApiStarter.AspNetCore/OpenTelemetryDefaults.cs +++ b/src/CleanApiStarter.AspNetCoreDefaults/OpenTelemetryDefaults.cs @@ -1,6 +1,6 @@ -namespace CleanApiStarter.AspNetCore; +namespace CleanApiStarter.AspNetCoreDefaults; -public static partial class Extensions +public static partial class AspNetCoreDefaultsExtensions { private static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder) { diff --git a/src/CleanApiStarter.AspNetCore/ProblemDetailsExceptionHandler.cs b/src/CleanApiStarter.AspNetCoreDefaults/ProblemDetailsExceptionHandler.cs similarity index 98% rename from src/CleanApiStarter.AspNetCore/ProblemDetailsExceptionHandler.cs rename to src/CleanApiStarter.AspNetCoreDefaults/ProblemDetailsExceptionHandler.cs index ee1e506..7cf981f 100644 --- a/src/CleanApiStarter.AspNetCore/ProblemDetailsExceptionHandler.cs +++ b/src/CleanApiStarter.AspNetCoreDefaults/ProblemDetailsExceptionHandler.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.AspNetCore; +namespace CleanApiStarter.AspNetCoreDefaults; public sealed class ProblemDetailsExceptionHandler( IProblemDetailsService problemDetailsService, diff --git a/src/CleanApiStarter.AspNetCore/RequestIdMiddleware.cs b/src/CleanApiStarter.AspNetCoreDefaults/RequestIdMiddleware.cs similarity index 83% rename from src/CleanApiStarter.AspNetCore/RequestIdMiddleware.cs rename to src/CleanApiStarter.AspNetCoreDefaults/RequestIdMiddleware.cs index 003a9ef..27c9215 100644 --- a/src/CleanApiStarter.AspNetCore/RequestIdMiddleware.cs +++ b/src/CleanApiStarter.AspNetCoreDefaults/RequestIdMiddleware.cs @@ -1,8 +1,8 @@ -namespace CleanApiStarter.AspNetCore; +namespace CleanApiStarter.AspNetCoreDefaults; public sealed class RequestIdMiddleware(RequestDelegate next) { - public const string HeaderName = "X-Request-ID"; + private const string HeaderName = "X-Request-ID"; public async Task InvokeAsync(HttpContext context) { diff --git a/src/CleanApiStarter.AspNetCore/UserIdHttpLoggingInterceptor.cs b/src/CleanApiStarter.AspNetCoreDefaults/UserIdHttpLoggingInterceptor.cs similarity index 95% rename from src/CleanApiStarter.AspNetCore/UserIdHttpLoggingInterceptor.cs rename to src/CleanApiStarter.AspNetCoreDefaults/UserIdHttpLoggingInterceptor.cs index 81c7243..0c613a3 100644 --- a/src/CleanApiStarter.AspNetCore/UserIdHttpLoggingInterceptor.cs +++ b/src/CleanApiStarter.AspNetCoreDefaults/UserIdHttpLoggingInterceptor.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.AspNetCore; +namespace CleanApiStarter.AspNetCoreDefaults; public sealed class UserIdHttpLoggingInterceptor : IHttpLoggingInterceptor { diff --git a/src/CleanApiStarter.AspNetCore/ValidationFilter.cs b/src/CleanApiStarter.AspNetCoreDefaults/ValidationFilter.cs similarity index 97% rename from src/CleanApiStarter.AspNetCore/ValidationFilter.cs rename to src/CleanApiStarter.AspNetCoreDefaults/ValidationFilter.cs index 63b2cc0..4a9d885 100644 --- a/src/CleanApiStarter.AspNetCore/ValidationFilter.cs +++ b/src/CleanApiStarter.AspNetCoreDefaults/ValidationFilter.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.AspNetCore; +namespace CleanApiStarter.AspNetCoreDefaults; public sealed class ValidationFilter : IEndpointFilter { diff --git a/src/CleanApiStarter.AspNetCore/WebApplicationExtensions.cs b/src/CleanApiStarter.AspNetCoreDefaults/WebApplicationExtensions.cs similarity index 96% rename from src/CleanApiStarter.AspNetCore/WebApplicationExtensions.cs rename to src/CleanApiStarter.AspNetCoreDefaults/WebApplicationExtensions.cs index 87e5166..bf66b9f 100644 --- a/src/CleanApiStarter.AspNetCore/WebApplicationExtensions.cs +++ b/src/CleanApiStarter.AspNetCoreDefaults/WebApplicationExtensions.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.AspNetCore; +namespace CleanApiStarter.AspNetCoreDefaults; public static class WebApplicationExtensions { diff --git a/src/CleanApiStarter.Configuration/CleanApiStarter.Configuration.csproj b/src/CleanApiStarter.Configuration/CleanApiStarter.Configuration.csproj deleted file mode 100644 index 002e9d2..0000000 --- a/src/CleanApiStarter.Configuration/CleanApiStarter.Configuration.csproj +++ /dev/null @@ -1,15 +0,0 @@ - - - - CleanApiStarter.Configuration - CleanApiStarter.Configuration - - - - - - - - - - diff --git a/src/CleanApiStarter.Configuration/GlobalUsings.cs b/src/CleanApiStarter.Configuration/GlobalUsings.cs deleted file mode 100644 index a2250c9..0000000 --- a/src/CleanApiStarter.Configuration/GlobalUsings.cs +++ /dev/null @@ -1,5 +0,0 @@ -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.Domain/CleanApiStarter.Domain.csproj b/src/CleanApiStarter.Domain/CleanApiStarter.Domain.csproj deleted file mode 100644 index 115c60c..0000000 --- a/src/CleanApiStarter.Domain/CleanApiStarter.Domain.csproj +++ /dev/null @@ -1,6 +0,0 @@ - - - CleanApiStarter.Domain - CleanApiStarter.Domain - - \ No newline at end of file diff --git a/src/CleanApiStarter.Infrastructure/CleanApiStarter.Infrastructure.csproj b/src/CleanApiStarter.Infrastructure/CleanApiStarter.Infrastructure.csproj deleted file mode 100644 index 71d039f..0000000 --- a/src/CleanApiStarter.Infrastructure/CleanApiStarter.Infrastructure.csproj +++ /dev/null @@ -1,20 +0,0 @@ - - - - CleanApiStarter.Infrastructure - CleanApiStarter.Infrastructure - - - - - - - - - - - - - - - diff --git a/src/CleanApiStarter.Infrastructure/GlobalUsings.cs b/src/CleanApiStarter.Infrastructure/GlobalUsings.cs deleted file mode 100644 index 8801542..0000000 --- a/src/CleanApiStarter.Infrastructure/GlobalUsings.cs +++ /dev/null @@ -1,21 +0,0 @@ -global using System.IdentityModel.Tokens.Jwt; -global using System.Security.Claims; -global using System.Text; - -global using CleanApiStarter.Application.Common.Models; -global using CleanApiStarter.Application.Features.Auth; -global using CleanApiStarter.Application.Features.Projects; -global using CleanApiStarter.Configuration; -global using CleanApiStarter.Domain.Entities; -global using CleanApiStarter.Infrastructure.Identity; -global using CleanApiStarter.Infrastructure.Persistence; -global using CleanApiStarter.Infrastructure.Repositories; - -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 Microsoft.IdentityModel.Tokens; diff --git a/src/CleanApiStarter.Infrastructure/Identity/ApplicationUser.cs b/src/CleanApiStarter.Infrastructure/Identity/ApplicationUser.cs deleted file mode 100644 index c801b9f..0000000 --- a/src/CleanApiStarter.Infrastructure/Identity/ApplicationUser.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace CleanApiStarter.Infrastructure.Identity; - -public sealed class ApplicationUser : IdentityUser; diff --git a/tests/CleanApiStarter.Application.UnitTests/GlobalUsings.cs b/tests/CleanApiStarter.Application.UnitTests/GlobalUsings.cs deleted file mode 100644 index 02c2906..0000000 --- a/tests/CleanApiStarter.Application.UnitTests/GlobalUsings.cs +++ /dev/null @@ -1,14 +0,0 @@ -global using AutoFixture.Xunit3; - -global using CleanApiStarter.Application.Common.Interfaces; -global using CleanApiStarter.Application.Features.Projects; -global using CleanApiStarter.Domain.Entities; -global using CleanApiStarter.Tests.Common; - -global using Microsoft.Extensions.Logging; - -global using NSubstitute; - -global using Shouldly; - -global using Xunit; diff --git a/tests/CleanApiStarter.Api.IntegrationTests/CleanApiStarter.Api.IntegrationTests.csproj b/tests/CleanApiStarter.IntegrationTests/CleanApiStarter.IntegrationTests.csproj similarity index 77% rename from tests/CleanApiStarter.Api.IntegrationTests/CleanApiStarter.Api.IntegrationTests.csproj rename to tests/CleanApiStarter.IntegrationTests/CleanApiStarter.IntegrationTests.csproj index 275bbf0..2bd0893 100644 --- a/tests/CleanApiStarter.Api.IntegrationTests/CleanApiStarter.Api.IntegrationTests.csproj +++ b/tests/CleanApiStarter.IntegrationTests/CleanApiStarter.IntegrationTests.csproj @@ -1,8 +1,8 @@  - CleanApiStarter.Api.IntegrationTests - CleanApiStarter.Api.IntegrationTests + CleanApiStarter.IntegrationTests + CleanApiStarter.IntegrationTests @@ -15,7 +15,7 @@ - + diff --git a/tests/CleanApiStarter.Api.IntegrationTests/Features/Projects/ProjectsTests.cs b/tests/CleanApiStarter.IntegrationTests/Features/Projects/ProjectsTests.cs similarity index 95% rename from tests/CleanApiStarter.Api.IntegrationTests/Features/Projects/ProjectsTests.cs rename to tests/CleanApiStarter.IntegrationTests/Features/Projects/ProjectsTests.cs index bc17707..43e8a74 100644 --- a/tests/CleanApiStarter.Api.IntegrationTests/Features/Projects/ProjectsTests.cs +++ b/tests/CleanApiStarter.IntegrationTests/Features/Projects/ProjectsTests.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Api.IntegrationTests.Features.Projects; +namespace CleanApiStarter.IntegrationTests.Features.Projects; public sealed class ProjectsTests : IClassFixture> { diff --git a/tests/CleanApiStarter.Api.IntegrationTests/GlobalUsings.cs b/tests/CleanApiStarter.IntegrationTests/GlobalUsings.cs similarity index 73% rename from tests/CleanApiStarter.Api.IntegrationTests/GlobalUsings.cs rename to tests/CleanApiStarter.IntegrationTests/GlobalUsings.cs index d1837d5..a576aa4 100644 --- a/tests/CleanApiStarter.Api.IntegrationTests/GlobalUsings.cs +++ b/tests/CleanApiStarter.IntegrationTests/GlobalUsings.cs @@ -2,7 +2,7 @@ global using System.Net.Http.Headers; global using System.Net.Http.Json; -global using CleanApiStarter.Tests.Common; +global using CleanApiStarter.TestUtilities.Common; global using Shouldly; diff --git a/tests/CleanApiStarter.Api.IntegrationTests/appsettings.Testing.json b/tests/CleanApiStarter.IntegrationTests/appsettings.Testing.json similarity index 100% rename from tests/CleanApiStarter.Api.IntegrationTests/appsettings.Testing.json rename to tests/CleanApiStarter.IntegrationTests/appsettings.Testing.json diff --git a/tests/CleanApiStarter.Tests/CleanApiStarter.Tests.csproj b/tests/CleanApiStarter.TestUtilities/CleanApiStarter.TestUtilities.csproj similarity index 85% rename from tests/CleanApiStarter.Tests/CleanApiStarter.Tests.csproj rename to tests/CleanApiStarter.TestUtilities/CleanApiStarter.TestUtilities.csproj index 3167dbc..ff57965 100644 --- a/tests/CleanApiStarter.Tests/CleanApiStarter.Tests.csproj +++ b/tests/CleanApiStarter.TestUtilities/CleanApiStarter.TestUtilities.csproj @@ -1,8 +1,8 @@  - CleanApiStarter.Tests - CleanApiStarter.Tests + CleanApiStarter.TestUtilities + CleanApiStarter.TestUtilities diff --git a/tests/CleanApiStarter.Tests/Common/ApiApplicationFactory.cs b/tests/CleanApiStarter.TestUtilities/Common/ApiApplicationFactory.cs similarity index 98% rename from tests/CleanApiStarter.Tests/Common/ApiApplicationFactory.cs rename to tests/CleanApiStarter.TestUtilities/Common/ApiApplicationFactory.cs index 01f01a0..1494598 100644 --- a/tests/CleanApiStarter.Tests/Common/ApiApplicationFactory.cs +++ b/tests/CleanApiStarter.TestUtilities/Common/ApiApplicationFactory.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Tests.Common; +namespace CleanApiStarter.TestUtilities.Common; public sealed class ApiApplicationFactory : WebApplicationFactory, IAsyncLifetime where TProgram : class diff --git a/tests/CleanApiStarter.Tests/Common/AutoNSubstituteDataAttribute.cs b/tests/CleanApiStarter.TestUtilities/Common/AutoNSubstituteDataAttribute.cs similarity index 84% rename from tests/CleanApiStarter.Tests/Common/AutoNSubstituteDataAttribute.cs rename to tests/CleanApiStarter.TestUtilities/Common/AutoNSubstituteDataAttribute.cs index 1ff103c..3befd72 100644 --- a/tests/CleanApiStarter.Tests/Common/AutoNSubstituteDataAttribute.cs +++ b/tests/CleanApiStarter.TestUtilities/Common/AutoNSubstituteDataAttribute.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Tests.Common; +namespace CleanApiStarter.TestUtilities.Common; public sealed class AutoNSubstituteDataAttribute() : AutoDataAttribute(CreateFixture) { diff --git a/tests/CleanApiStarter.Tests/GlobalUsings.cs b/tests/CleanApiStarter.TestUtilities/GlobalUsings.cs similarity index 100% rename from tests/CleanApiStarter.Tests/GlobalUsings.cs rename to tests/CleanApiStarter.TestUtilities/GlobalUsings.cs diff --git a/tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj b/tests/CleanApiStarter.UnitTests/CleanApiStarter.UnitTests.csproj similarity index 65% rename from tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj rename to tests/CleanApiStarter.UnitTests/CleanApiStarter.UnitTests.csproj index 84de8d1..a8c0ef0 100644 --- a/tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj +++ b/tests/CleanApiStarter.UnitTests/CleanApiStarter.UnitTests.csproj @@ -1,8 +1,8 @@ - CleanApiStarter.Application.UnitTests - CleanApiStarter.Application.UnitTests + CleanApiStarter.UnitTests + CleanApiStarter.UnitTests @@ -17,8 +17,12 @@ - - + + + + + + diff --git a/tests/CleanApiStarter.Application.UnitTests/Features/Projects/ProjectServiceTests.cs b/tests/CleanApiStarter.UnitTests/Features/Projects/Tasks/CompleteTaskTests.cs similarity index 67% rename from tests/CleanApiStarter.Application.UnitTests/Features/Projects/ProjectServiceTests.cs rename to tests/CleanApiStarter.UnitTests/Features/Projects/Tasks/CompleteTaskTests.cs index 73c9e68..8209347 100644 --- a/tests/CleanApiStarter.Application.UnitTests/Features/Projects/ProjectServiceTests.cs +++ b/tests/CleanApiStarter.UnitTests/Features/Projects/Tasks/CompleteTaskTests.cs @@ -1,16 +1,15 @@ -namespace CleanApiStarter.Application.UnitTests.Features.Projects; +namespace CleanApiStarter.UnitTests.Features.Projects.Tasks; -public sealed class ProjectServiceTests +public sealed class CompleteTaskTests { [Theory] [AutoNSubstituteData] - public async Task CompleteTaskAsync_TaskIsAlreadyCompleted_ReturnsAlreadyCompleted( + public async Task Handle_TaskIsAlreadyCompleted_ReturnsConflictAndDoesNotSave( Guid projectId, Guid taskId, string userId, [Frozen] IUser currentUser, - [Frozen] IProjectRepository projectRepository, - ProjectService sut) + [Frozen] IProjectRepository projectRepository) { // Arrange CancellationToken cancellationToken = CancellationToken.None; @@ -31,10 +30,10 @@ public async Task CompleteTaskAsync_TaskIsAlreadyCompleted_ReturnsAlreadyComplet .Returns(Task.FromResult(task)); // Act - ProjectTaskMutationResult result = await sut.CompleteTaskAsync(projectId, taskId, cancellationToken); + IResult result = await CompleteTask.Handle(projectId, taskId, projectRepository, currentUser, cancellationToken); // Assert - result.ShouldBe(ProjectTaskMutationResult.AlreadyCompleted); + result.ShouldBeOfType>(); _ = projectRepository.DidNotReceive().SaveChangesAsync(Arg.Any()); } } diff --git a/tests/CleanApiStarter.UnitTests/GlobalUsings.cs b/tests/CleanApiStarter.UnitTests/GlobalUsings.cs new file mode 100644 index 0000000..b0a244a --- /dev/null +++ b/tests/CleanApiStarter.UnitTests/GlobalUsings.cs @@ -0,0 +1,16 @@ +global using AutoFixture.Xunit3; + +global using CleanApiStarter.Api.Common.Interfaces; +global using CleanApiStarter.Api.Domain.Entities; +global using CleanApiStarter.Api.Features.Projects; +global using CleanApiStarter.Api.Features.Projects.Tasks; +global using CleanApiStarter.TestUtilities.Common; + +global using Microsoft.AspNetCore.Http; +global using Microsoft.AspNetCore.Http.HttpResults; + +global using NSubstitute; + +global using Shouldly; + +global using Xunit;