diff --git a/.editorconfig b/.editorconfig index a8cffa2..85831aa 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,13 +1,21 @@ root = true +# EditorConfig is awesome: http://EditorConfig.org +# top-most EditorConfig file + +# Global settings [*] -end_of_line = crlf insert_final_newline = true trim_trailing_whitespace = true max_line_length = 190 + +# ReSharper properties +resharper_max_initializer_elements_on_line = 1 + + # Xml project files -[*.csproj] +[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}] indent_style = space indent_size = 2 @@ -24,38 +32,153 @@ indent_size = 4 indent_style = space indent_size = 4 -# Code style -csharp_keep_existing_declaration_block_arrangement = true -csharp_place_simple_blocks_on_single_line = false +# New line preferences +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true +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_switch_labels = true +csharp_indent_labels = one_less_than_current + +# avoid this. unless absolutely necessary +dotnet_style_qualification_for_field = false:suggestion +dotnet_style_qualification_for_property = false:suggestion +dotnet_style_qualification_for_method = false:suggestion +dotnet_style_qualification_for_event = false:suggestion + +# only use var when it's obvious what the variable type is +csharp_style_var_for_built_in_types = false:none +csharp_style_var_when_type_is_apparent = false:none +csharp_style_var_elsewhere = false:suggestion + +# use language keywords instead of BCL types +dotnet_style_predefined_type_for_locals_parameters_members = true:error +dotnet_style_predefined_type_for_member_access = true:error + +# name all constant fields using PascalCase +dotnet_naming_rule.constant_fields_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.constant_fields_should_be_pascal_case.symbols = constant_fields +dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case_style + +dotnet_naming_symbols.constant_fields.applicable_kinds = field +dotnet_naming_symbols.constant_fields.required_modifiers = const + +dotnet_naming_style.pascal_case_style.capitalization = pascal_case + +dotnet_naming_rule.private_members_with_underscore.symbols = private_fields +dotnet_naming_rule.private_members_with_underscore.style = prefix_underscore +dotnet_naming_rule.private_members_with_underscore.severity = suggestion + +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private + +dotnet_naming_style.prefix_underscore.capitalization = camel_case +dotnet_naming_style.prefix_underscore.required_prefix = _ + +# Code style defaults +csharp_using_directive_placement = outside_namespace:warning +csharp_style_namespace_declarations = file_scoped:warning +dotnet_sort_system_directives_first = true +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = false + +# Expression-level preferences +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = true:suggestion + +# Expression-bodied members +csharp_style_expression_bodied_methods = true:none +csharp_style_expression_bodied_constructors = false:none +csharp_style_expression_bodied_operators = false:none +csharp_style_expression_bodied_properties = true:none +csharp_style_expression_bodied_indexers = true:none +csharp_style_expression_bodied_accessors = true:none + +# Pattern matching +csharp_style_pattern_matching_over_is_with_cast_check = true:error +csharp_style_pattern_matching_over_as_with_null_check = true:error +csharp_style_inlined_variable_declaration = true:error + +# Null checking preferences +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion + +dotnet_style_require_accessibility_modifiers = for_non_interface_members:error +dotnet_style_readonly_field = true:error + +# 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 = do_not_ignore +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_attribute_sections = 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 + +# FxCop Analyzers +#dotnet_diagnostic.ca1056.severity = none +#dotnet_diagnostic.ca1054.severity = none +#dotnet_diagnostic.ca1002.severity = none +#dotnet_diagnostic.ca1051.severity = none +#dotnet_diagnostic.ca1030.severity = none +#dotnet_diagnostic.ca1848.severity = none +#dotnet_diagnostic.ca1716.severity = none +#dotnet_diagnostic.ca1034.severity = none +# In externally visible method validate parameter is non-null before using it. +dotnet_diagnostic.ca1062.severity = none +#dotnet_diagnostic.ca2227.severity = none +#dotnet_diagnostic.ca1810.severity = none +#dotnet_diagnostic.ca2234.severity = none +#dotnet_diagnostic.ca1707.severity = none + + +dotnet_code_quality.ca1062.exclude_extension_method_this_parameter = true +dotnet_code_quality.exclude_extension_method_this_parameter = true dotnet_code_quality.null_check_validation_methods = ThrowIfArgumentIsNull +# CA2007: Do not directly await a Task +dotnet_diagnostic.ca2007.severity = none +# CA1303: Do not pass literals as localized parameters +dotnet_diagnostic.ca1303.severity = none + +# NUnit1032: An IDisposable field/property should be Disposed in a TearDown method +dotnet_diagnostic.nunit1032.severity = none +# CA1812: Avoid uninstantiated internal classes +dotnet_diagnostic.ca1812.severity = none +# Properties should not return arrays +dotnet_diagnostic.ca1819.severity = none -# Dispose objects before losing scope -dotnet_diagnostic.CA2000.severity = none -# Do not directly await a Task -dotnet_diagnostic.CA2007.severity = none -# Use the LoggerMessage delegates -dotnet_diagnostic.CA1848.severity = none -# Validate arguments of public methods -dotnet_diagnostic.CA1062.severity = none -# Do not pass literals as localized parameters -dotnet_diagnostic.CA1303.severity = none -# Do not catch general exception types -dotnet_diagnostic.CA1031.severity = none -#Do not expose generic lists -dotnet_diagnostic.CA1002.severity = none -# Identifiers should not contain underscores +dotnet_diagnostic.ca1014.severity = none + +[**.Tests/**.cs] +# Remove the underscores from member name dotnet_diagnostic.CA1707.severity = none -# Avoid uninstantiated internal classes -dotnet_diagnostic.CA1812.severity = none -#Identifiers should not have incorrect suffix -dotnet_diagnostic.CA1711.severity = none -#Pass System.Uri objects instead of strings + dotnet_diagnostic.CA2234.severity = none -#Nested types should not be visible -dotnet_diagnostic.CA1034.severity = none -#Static holder types should be Static or NotInheritable -dotnet_diagnostic.CA1052.severity = none -#Do not raise reserved exception types -dotnet_diagnostic.CA2201.severity = none -#Normalize strings to uppercase -dotnet_diagnostic.CA1308.severity = none \ No newline at end of file + diff --git a/.gitattributes b/.gitattributes index 6313b56..1e3cb56 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,32 @@ -* text=auto eol=lf +* text=auto + +# Force batch windows scripts to always use CRLF line endings +*.{cmd,[cC][mM][dD]} text eol=crlf +*.{bat,[bB][aA][tT]} text eol=crlf +*.{ics,[iI][cC][sS]} text eol=crlf + +# Force linux scripts to always use LF line endings +*.sh text eol=lf + + # Archives +*.7z filter=lfs diff=lfs merge=lfs -text +*.br filter=lfs diff=lfs merge=lfs -text +*.gz filter=lfs diff=lfs merge=lfs -text +*.tar filter=lfs diff=lfs merge=lfs -text +*.zip filter=lfs diff=lfs merge=lfs -text + +# Images +*.gif filter=lfs diff=lfs merge=lfs -text +*.ico filter=lfs diff=lfs merge=lfs -text +*.png filter=lfs diff=lfs merge=lfs -text +*.jpg filter=lfs diff=lfs merge=lfs -text +*.jpeg filter=lfs diff=lfs merge=lfs -text +*.svg filter=lfs diff=lfs merge=lfs -text +*.psd filter=lfs diff=lfs merge=lfs -text +*.webp filter=lfs diff=lfs merge=lfs -text + +# Fonts +*.woff2 filter=lfs diff=lfs merge=lfs -text + +# Other +*.exe filter=lfs diff=lfs merge=lfs -text diff --git a/CleanAspCore.Api.Tests/.editorconfig b/CleanAspCore.Api.Tests/.editorconfig deleted file mode 100644 index 7c9add1..0000000 --- a/CleanAspCore.Api.Tests/.editorconfig +++ /dev/null @@ -1,3 +0,0 @@ -[*.cs] -# NUnit1032: An IDisposable field/property should be Disposed in a TearDown method -dotnet_diagnostic.nunit1032.severity = none diff --git a/CleanAspCore.Api.Tests/CleanAspCore.Api.Tests.csproj b/CleanAspCore.Api.Tests/CleanAspCore.Api.Tests.csproj index a741294..4f41bd5 100644 --- a/CleanAspCore.Api.Tests/CleanAspCore.Api.Tests.csproj +++ b/CleanAspCore.Api.Tests/CleanAspCore.Api.Tests.csproj @@ -10,26 +10,26 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - - - + + + - - + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/CleanAspCore.Api.Tests/Features/Departments/DepartmentControllerTests.cs b/CleanAspCore.Api.Tests/Features/Departments/DepartmentControllerTests.cs index 7cb30f0..227571d 100644 --- a/CleanAspCore.Api.Tests/Features/Departments/DepartmentControllerTests.cs +++ b/CleanAspCore.Api.Tests/Features/Departments/DepartmentControllerTests.cs @@ -1,4 +1,5 @@ -using CleanAspCore.Domain; +using System.Net; +using CleanAspCore.Domain; using CleanAspCore.Domain.Departments; using CleanAspCore.Features.Import; @@ -7,22 +8,43 @@ namespace CleanAspCore.Api.Tests.Features.Departments; public class DepartmentControllerTests : TestBase { [Test] - public async Task SearchDepartments_ReturnsExpectedDepartments() + public async Task GetDepartmentById_ReturnsExpectedDepartment() { //Arrange - var department = Fakers.CreateDepartmentFaker().Generate(); + var department = new DepartmentFaker().Generate(); Sut.SeedData(context => { context.Departments.Add(department); }); //Act - var result = await Sut.CreateClient().GetFromJsonAsync("Department"); + var result = await Sut.CreateClient().GetFromJsonAsync($"departments/{department.Id}"); //Assert - result.Should().BeEquivalentTo(new[] + result.Should().BeEquivalentTo(department.ToDto()); + } + + [Test] + public async Task AddDepartment_IsAdded() + { + //Arrange + var department = new DepartmentFaker().Generate(); + + //Act + var response = await Sut.CreateClient().PostAsJsonAsync("departments", department.ToDto()); + await response.AssertStatusCode(HttpStatusCode.Created); + var createdId = response.GetGuidFromLocationHeader(); + + //Assert + Sut.AssertDatabase(context => { - department - }, c => c.ComparingByMembers().ExcludingMissingMembers()); + context.Departments.Should().BeEquivalentTo(new[] + { + new + { + Id = createdId + } + }); + }); } } diff --git a/CleanAspCore.Api.Tests/Features/Employees/EmployeeControllerTests.cs b/CleanAspCore.Api.Tests/Features/Employees/EmployeeControllerTests.cs index 4eb60ec..092bf94 100644 --- a/CleanAspCore.Api.Tests/Features/Employees/EmployeeControllerTests.cs +++ b/CleanAspCore.Api.Tests/Features/Employees/EmployeeControllerTests.cs @@ -1,5 +1,8 @@ -using CleanAspCore.Domain; +using System.Net; +using System.Web; +using CleanAspCore.Domain; using CleanAspCore.Domain.Employees; +using CleanAspCore.Features.Employees; using CleanAspCore.Features.Import; namespace CleanAspCore.Api.Tests.Features.Employees; @@ -7,51 +10,47 @@ namespace CleanAspCore.Api.Tests.Features.Employees; public class EmployeeControllerTests : TestBase { [Test] - public async Task SearchEmployee_ReturnsExpectedJobs() + public async Task GetEmployeeById_ReturnsExpectedEmployee() { //Arrange - var employee = Fakers.CreateEmployeeFaker().Generate(); + var employee = new EmployeeFaker().Generate(); Sut.SeedData(context => { context.Employees.Add(employee); }); //Act - var result = await Sut.CreateClient().GetFromJsonAsync("Employee"); + var result = await Sut.CreateClient().GetFromJsonAsync($"employees/{employee.Id}"); //Assert - result.Should().BeEquivalentTo(new[] - { - employee - }, c => c.ComparingByMembers().ExcludingMissingMembers()); + result.Should().BeEquivalentTo(employee.ToDto()); } [Test] public async Task AddEmployee_IsAdded() { //Arrange - var employee = Fakers.CreateEmployeeFaker().Generate(); + var employee = new EmployeeFaker().Generate(); Sut.SeedData(context => { context.Departments.Add(employee.Department!); context.Jobs.Add(employee.Job!); }); - employee.DepartmentId = employee.Department!.Id; - employee.JobId = employee.Job!.Id; - //Act - var result = await Sut.CreateClient().PostAsJsonAsync("Employee", employee.ToDto()); - result.EnsureSuccessStatusCode(); - var createdEmployee = await result.Content.ReadFromJsonAsync(); + var response = await Sut.CreateClient().PostAsJsonAsync("employees", employee.ToDto()); + await response.AssertStatusCode(HttpStatusCode.Created); + var createdId = response.GetGuidFromLocationHeader(); //Assert - createdEmployee.Should().NotBeNull(); Sut.AssertDatabase(context => { context.Employees.Should().BeEquivalentTo(new[] { - createdEmployee!.ToDomain() + new + { + Id = createdId + } }); }); } @@ -60,25 +59,28 @@ public async Task AddEmployee_IsAdded() public async Task UpdateEmployee_IsUpdated() { //Arrange - var employee = Fakers.CreateEmployeeFaker().Generate(); + var employee = new EmployeeFaker().Generate(); Sut.SeedData(context => { context.Employees.Add(employee); }); - var updatedEmployee = employee.ToDto() with + UpdateEmployeeRequest updateEmployeeRequest = new() { - FirstName = "Updated", - LastName = "Updated" + FirstName = "Updated" }; //Act - var result = await Sut.CreateClient().PutAsJsonAsync("Employee", updatedEmployee); + var result = await Sut.CreateClient().PutAsJsonAsync($"employees/{employee.Id}", updateEmployeeRequest); //Assert - result.EnsureSuccessStatusCode(); + await result.AssertStatusCode(HttpStatusCode.NoContent); Sut.AssertDatabase(context => { context.Employees.Should().BeEquivalentTo(new[] { - updatedEmployee.ToDomain() + new + { + FirstName = "Updated", + LastName = employee.LastName, + } }); }); } @@ -87,14 +89,14 @@ public async Task UpdateEmployee_IsUpdated() public async Task DeleteEmployee_IsDeleted() { //Arrange - var employee = Fakers.CreateEmployeeFaker().Generate(); + var employee = new EmployeeFaker().Generate(); Sut.SeedData(context => { context.Employees.Add(employee); }); //Act - var result = await Sut.CreateClient().DeleteAsync($"Employee/{employee.Id}"); + var result = await Sut.CreateClient().DeleteAsync($"employees/{employee.Id}"); //Assert result.EnsureSuccessStatusCode(); diff --git a/CleanAspCore.Api.Tests/Features/Import/ImportControllerTests.cs b/CleanAspCore.Api.Tests/Features/Import/ImportControllerTests.cs index db66aab..41e9feb 100644 --- a/CleanAspCore.Api.Tests/Features/Import/ImportControllerTests.cs +++ b/CleanAspCore.Api.Tests/Features/Import/ImportControllerTests.cs @@ -6,7 +6,7 @@ public class ImportControllerTests : TestBase public async Task Import_SingleNewEmployee_IsImported() { //Act - var result = await Sut.CreateClient().PutAsync("Import", null); + var result = await Sut.CreateClient().PutAsync("import", null); result.EnsureSuccessStatusCode(); //Assert diff --git a/CleanAspCore.Api.Tests/Features/Jobs/JobControllerTests.cs b/CleanAspCore.Api.Tests/Features/Jobs/JobControllerTests.cs index 2717a57..113eaef 100644 --- a/CleanAspCore.Api.Tests/Features/Jobs/JobControllerTests.cs +++ b/CleanAspCore.Api.Tests/Features/Jobs/JobControllerTests.cs @@ -1,3 +1,4 @@ +using System.Net; using CleanAspCore.Domain; using CleanAspCore.Domain.Jobs; using CleanAspCore.Features.Import; @@ -10,19 +11,40 @@ public class JobControllerTests : TestBase public async Task SearchJobs_ReturnsExpectedJobs() { //Arrange - var job = Fakers.CreateJobFaker().Generate(); + var job = new JobFaker().Generate(); Sut.SeedData(context => { context.Jobs.Add(job); }); //Act - var result = await Sut.CreateClient().GetFromJsonAsync("Job"); + var result = await Sut.CreateClient().GetFromJsonAsync($"jobs/{job.Id}"); //Assert - result.Should().BeEquivalentTo(new[] + result.Should().BeEquivalentTo(job.ToDto()); + } + + [Test] + public async Task AddJob_IsAdded() + { + //Arrange + var job = new JobFaker().Generate(); + + //Act + var response = await Sut.CreateClient().PostAsJsonAsync("jobs", job.ToDto()); + await response.AssertStatusCode(HttpStatusCode.Created); + var createdId = response.GetGuidFromLocationHeader(); + + //Assert + Sut.AssertDatabase(context => { - job - }, c => c.ComparingByMembers().ExcludingMissingMembers()); + context.Jobs.Should().BeEquivalentTo(new[] + { + new + { + Id = createdId + } + }); + }); } } diff --git a/CleanAspCore.Api.Tests/HttpAssertionExtensions.cs b/CleanAspCore.Api.Tests/HttpAssertionExtensions.cs new file mode 100644 index 0000000..3b7fd13 --- /dev/null +++ b/CleanAspCore.Api.Tests/HttpAssertionExtensions.cs @@ -0,0 +1,38 @@ +using System.Net; +using FluentAssertions.Execution; +using Microsoft.AspNetCore.Http; + +namespace CleanAspCore.Api.Tests; + +public static class HttpAssertionExtensions +{ + public static async Task AssertStatusCode(this HttpResponseMessage response, HttpStatusCode expected) + { + if(expected != HttpStatusCode.BadRequest) + { + using(var _ = new AssertionScope()) + { + response.StatusCode.Should().Be(expected); + if (response.StatusCode == HttpStatusCode.BadRequest) + { + var problemDetails = await response.Content.ReadFromJsonAsync(); + if (problemDetails != null) + { + var message = string.Join(Environment.NewLine, problemDetails.Errors.Select(x => $"{x.Key}: {x.Value[0]}")); + _.FailWith(message); + } + } + } + } + else + { + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + } + + public static Guid GetGuidFromLocationHeader(this HttpResponseMessage response) + { + var segments = response.Headers.Location!.Segments; + return Guid.Parse(segments.Last()); + } +} diff --git a/CleanAspCore.sln b/CleanAspCore.sln index 7cf36c1..de78239 100644 --- a/CleanAspCore.sln +++ b/CleanAspCore.sln @@ -6,6 +6,11 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Docker", "Docker", "{3CFBE5 ProjectSection(SolutionItems) = preProject docker-compose.yaml = docker-compose.yaml Dockerfile = Dockerfile + .editorconfig = .editorconfig + .gitattributes = .gitattributes + .gitignore = .gitignore + Directory.Build.props = Directory.Build.props + global.json = global.json EndProjectSection EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleanAspCore.Api.Tests", "CleanAspCore.Api.Tests\CleanAspCore.Api.Tests.csproj", "{4B45D679-E787-4236-BD9A-383364CD0E6F}" diff --git a/CleanAspCore/CleanAspCore.csproj b/CleanAspCore/CleanAspCore.csproj index 4875f3a..eea5f67 100644 --- a/CleanAspCore/CleanAspCore.csproj +++ b/CleanAspCore/CleanAspCore.csproj @@ -1,39 +1,32 @@ - - net8.0 - enable - enable - + + net8.0 + enable + enable + - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + - - <_ContentIncludedByDefault Remove="TestData\Department.json" /> - <_ContentIncludedByDefault Remove="TestData\Employee.json" /> - <_ContentIncludedByDefault Remove="TestData\Job.json" /> - + + <_ContentIncludedByDefault Remove="TestData\Department.json"/> + <_ContentIncludedByDefault Remove="TestData\Employee.json"/> + <_ContentIncludedByDefault Remove="TestData\Job.json"/> + diff --git a/CleanAspCore/Data/Migrations/20240426115911_UseGuid.Designer.cs b/CleanAspCore/Data/Migrations/20240426115911_UseGuid.Designer.cs new file mode 100644 index 0000000..0e40437 --- /dev/null +++ b/CleanAspCore/Data/Migrations/20240426115911_UseGuid.Designer.cs @@ -0,0 +1,137 @@ +// +using System; +using CleanAspCore.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace CleanAspCore.Persistance.Migrations +{ + [DbContext(typeof(HrContext))] + [Migration("20240426115911_UseGuid")] + partial class UseGuid + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("CleanAspCore.Domain.Departments.Department", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("City") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Departments"); + }); + + modelBuilder.Entity("CleanAspCore.Domain.Employees.Employee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DepartmentId") + .HasColumnType("uuid"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Gender") + .IsRequired() + .HasColumnType("text"); + + b.Property("JobId") + .HasColumnType("uuid"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("JobId"); + + b.ToTable("Employees"); + }); + + modelBuilder.Entity("CleanAspCore.Domain.Jobs.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Jobs"); + }); + + modelBuilder.Entity("CleanAspCore.Domain.Employees.Employee", b => + { + b.HasOne("CleanAspCore.Domain.Departments.Department", "Department") + .WithMany() + .HasForeignKey("DepartmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CleanAspCore.Domain.Jobs.Job", "Job") + .WithMany() + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("CleanAspCore.Domain.Employees.EmailAddress", "Email", b1 => + { + b1.Property("EmployeeId") + .HasColumnType("uuid"); + + b1.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("Email"); + + b1.HasKey("EmployeeId"); + + b1.ToTable("Employees"); + + b1.WithOwner() + .HasForeignKey("EmployeeId"); + }); + + b.Navigation("Department"); + + b.Navigation("Email") + .IsRequired(); + + b.Navigation("Job"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/CleanAspCore/Data/Migrations/20240426115911_UseGuid.cs b/CleanAspCore/Data/Migrations/20240426115911_UseGuid.cs new file mode 100644 index 0000000..abc5180 --- /dev/null +++ b/CleanAspCore/Data/Migrations/20240426115911_UseGuid.cs @@ -0,0 +1,106 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace CleanAspCore.Persistance.Migrations +{ + /// + public partial class UseGuid : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Id", + table: "Jobs", + type: "uuid", + nullable: false, + oldClrType: typeof(int), + oldType: "integer") + .OldAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + migrationBuilder.AlterColumn( + name: "JobId", + table: "Employees", + type: "uuid", + nullable: false, + oldClrType: typeof(int), + oldType: "integer"); + + migrationBuilder.AlterColumn( + name: "DepartmentId", + table: "Employees", + type: "uuid", + nullable: false, + oldClrType: typeof(int), + oldType: "integer"); + + migrationBuilder.AlterColumn( + name: "Id", + table: "Employees", + type: "uuid", + nullable: false, + oldClrType: typeof(int), + oldType: "integer") + .OldAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + migrationBuilder.AlterColumn( + name: "Id", + table: "Departments", + type: "uuid", + nullable: false, + oldClrType: typeof(int), + oldType: "integer") + .OldAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Id", + table: "Jobs", + type: "integer", + nullable: false, + oldClrType: typeof(Guid), + oldType: "uuid") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + migrationBuilder.AlterColumn( + name: "JobId", + table: "Employees", + type: "integer", + nullable: false, + oldClrType: typeof(Guid), + oldType: "uuid"); + + migrationBuilder.AlterColumn( + name: "DepartmentId", + table: "Employees", + type: "integer", + nullable: false, + oldClrType: typeof(Guid), + oldType: "uuid"); + + migrationBuilder.AlterColumn( + name: "Id", + table: "Employees", + type: "integer", + nullable: false, + oldClrType: typeof(Guid), + oldType: "uuid") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + migrationBuilder.AlterColumn( + name: "Id", + table: "Departments", + type: "integer", + nullable: false, + oldClrType: typeof(Guid), + oldType: "uuid") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + } + } +} diff --git a/CleanAspCore/Data/Migrations/HrContextModelSnapshot.cs b/CleanAspCore/Data/Migrations/HrContextModelSnapshot.cs index 3df6b1d..262b31b 100644 --- a/CleanAspCore/Data/Migrations/HrContextModelSnapshot.cs +++ b/CleanAspCore/Data/Migrations/HrContextModelSnapshot.cs @@ -1,4 +1,5 @@ // +using System; using CleanAspCore.Data; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -16,18 +17,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "7.0.8") + .HasAnnotation("ProductVersion", "8.0.4") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); modelBuilder.Entity("CleanAspCore.Domain.Departments.Department", b => { - b.Property("Id") + b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + .HasColumnType("uuid"); b.Property("City") .IsRequired() @@ -44,14 +43,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("CleanAspCore.Domain.Employees.Employee", b => { - b.Property("Id") + b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + .HasColumnType("uuid"); - b.Property("DepartmentId") - .HasColumnType("integer"); + b.Property("DepartmentId") + .HasColumnType("uuid"); b.Property("FirstName") .IsRequired() @@ -61,8 +58,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("text"); - b.Property("JobId") - .HasColumnType("integer"); + b.Property("JobId") + .HasColumnType("uuid"); b.Property("LastName") .IsRequired() @@ -79,11 +76,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("CleanAspCore.Domain.Jobs.Job", b => { - b.Property("Id") + b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + .HasColumnType("uuid"); b.Property("Name") .IsRequired() @@ -110,8 +105,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.OwnsOne("CleanAspCore.Domain.Employees.EmailAddress", "Email", b1 => { - b1.Property("EmployeeId") - .HasColumnType("integer"); + b1.Property("EmployeeId") + .HasColumnType("uuid"); b1.Property("Email") .IsRequired() diff --git a/CleanAspCore/Domain/Departments/Department.cs b/CleanAspCore/Domain/Departments/Department.cs index ee491a9..1103cbe 100644 --- a/CleanAspCore/Domain/Departments/Department.cs +++ b/CleanAspCore/Domain/Departments/Department.cs @@ -1,18 +1,26 @@ -using Riok.Mapperly.Abstractions; - -namespace CleanAspCore.Domain.Departments; +namespace CleanAspCore.Domain.Departments; public class Department : Entity { - public required string Name { get; set; } - public required string City { get; set; } + public required string Name { get; init; } + public required string City { get; init; } } -public sealed record DepartmentDto(int Id, string Name, string City); +public sealed record DepartmentDto(Guid Id, string Name, string City); -[Mapper] -public static partial class DepartmentMapper +public static class DepartmentMapper { - public static partial DepartmentDto ToDto(this Department department); - public static partial Department ToDomain(this DepartmentDto department); -} \ No newline at end of file + public static DepartmentDto ToDto(this Department department) => new + ( + department.Id, + department.Name, + department.City + ); + + public static Department ToDomain(this DepartmentDto department) => new() + { + Id = department.Id, + Name = department.Name, + City = department.City + }; +} diff --git a/CleanAspCore/Domain/Employees/Employee.cs b/CleanAspCore/Domain/Employees/Employee.cs index f9ce29c..3236d9d 100644 --- a/CleanAspCore/Domain/Employees/Employee.cs +++ b/CleanAspCore/Domain/Employees/Employee.cs @@ -1,6 +1,5 @@ using CleanAspCore.Domain.Departments; using CleanAspCore.Domain.Jobs; -using Riok.Mapperly.Abstractions; namespace CleanAspCore.Domain.Employees; @@ -10,28 +9,33 @@ public class Employee : Entity public required string LastName { get; set; } public required EmailAddress Email { get; set; } public required string Gender { get; set; } - public virtual Department? Department { get; set; } - public required int DepartmentId { get; set; } - public virtual Job? Job { get; set; } - public required int JobId { get; set; } + public virtual Department? Department { get; init; } + public required Guid DepartmentId { get; set; } + public virtual Job? Job { get; init; } + public required Guid JobId { get; set; } } -public sealed record EmployeeDto(int? Id, string FirstName, string LastName, string Email, string Gender, int DepartmentId, int JobId); +public sealed record EmployeeDto(string FirstName, string LastName, string Email, string Gender, Guid DepartmentId, Guid JobId); -public class EmployeeValidator : AbstractValidator +public static class EmployeeMapper { - public EmployeeValidator() + public static EmployeeDto ToDto(this Employee employee) => new( + employee.FirstName, + employee.LastName, + employee.Email.ToString(), + employee.Gender, + employee.DepartmentId, + employee.JobId + ); + + public static Employee ToDomain(this EmployeeDto employee) => new() { - RuleFor(x => x.FirstName).NotEmpty(); - RuleFor(x => x.LastName).NotEmpty(); - RuleFor(x => x.Email).NotNull(); - } + Id = Guid.NewGuid(), + FirstName = employee.FirstName, + LastName = employee.LastName, + Email = new EmailAddress(employee.Email), + Gender = employee.Gender, + DepartmentId = employee.DepartmentId, + JobId = employee.JobId + }; } - -[Mapper] -public static partial class EmployeeMapper -{ - public static partial EmployeeDto ToDto(this Employee employee); - - public static partial Employee ToDomain(this EmployeeDto employee); -} \ No newline at end of file diff --git a/CleanAspCore/Domain/Entity.cs b/CleanAspCore/Domain/Entity.cs index 327cea1..5e71a5e 100644 --- a/CleanAspCore/Domain/Entity.cs +++ b/CleanAspCore/Domain/Entity.cs @@ -2,7 +2,7 @@ public abstract class Entity { - public int Id { get; init; } + public required Guid Id { get; init; } public override bool Equals(object? obj) { @@ -32,4 +32,4 @@ public override bool Equals(object? obj) } public override int GetHashCode() => Id.GetHashCode(); -} \ No newline at end of file +} diff --git a/CleanAspCore/Domain/Jobs/Job.cs b/CleanAspCore/Domain/Jobs/Job.cs index 7cf87a5..465c33e 100644 --- a/CleanAspCore/Domain/Jobs/Job.cs +++ b/CleanAspCore/Domain/Jobs/Job.cs @@ -1,17 +1,22 @@ -using Riok.Mapperly.Abstractions; - -namespace CleanAspCore.Domain.Jobs; +namespace CleanAspCore.Domain.Jobs; public class Job : Entity { public required string Name { get; set; } } -public sealed record JobDto(int Id, string Name); +public sealed record JobDto(Guid Id, string Name); -[Mapper] -public static partial class JobMapper +public static class JobMapper { - public static partial JobDto ToDto(this Job department); - public static partial Job ToDomain(this JobDto department); -} \ No newline at end of file + public static JobDto ToDto(this Job department) => new( + department.Id, + department.Name + ); + + public static Job ToDomain(this JobDto department) => new() + { + Id = Guid.NewGuid(), + Name = department.Name + }; +} diff --git a/CleanAspCore/EndpointRouteBuilderExtensions.cs b/CleanAspCore/EndpointRouteBuilderExtensions.cs deleted file mode 100644 index 5309139..0000000 --- a/CleanAspCore/EndpointRouteBuilderExtensions.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace CleanAspCore; - -public static class EndpointRouteBuilderExtensions -{ - public static void AddRouteModules(this IEndpointRouteBuilder host) - { - var modules = host.ServiceProvider.GetServices(); - foreach (var routeModule in modules) - { - routeModule.AddRoutes(host); - } - } -} \ No newline at end of file diff --git a/CleanAspCore/Features/Departments/AddDepartments.cs b/CleanAspCore/Features/Departments/AddDepartments.cs index 5dc440e..dc3beb1 100644 --- a/CleanAspCore/Features/Departments/AddDepartments.cs +++ b/CleanAspCore/Features/Departments/AddDepartments.cs @@ -1,26 +1,18 @@ using CleanAspCore.Data; using CleanAspCore.Domain.Departments; +using Microsoft.AspNetCore.Http.HttpResults; namespace CleanAspCore.Features.Departments; -public static class AddDepartments +internal static class AddDepartments { - public record Request(List Departments) : IRequest; - - public class Handler : IRequestHandler + public static async Task Handle(HrContext context, DepartmentDto createDepartmentRequest, CancellationToken cancellationToken) { - private readonly HrContext _context; + var department = createDepartmentRequest.ToDomain(); - public Handler(HrContext context) - { - _context = context; - } + context.Departments.AddRange(department); + await context.SaveChangesAsync(cancellationToken); - public async ValueTask Handle(Request request, CancellationToken cancellationToken) - { - _context.Departments.AddRange(request.Departments); - await _context.SaveChangesAsync(cancellationToken); - return Unit.Value; - } + return TypedResults.CreatedAtRoute(nameof(GetDepartmentById), new { department.Id }); } -} \ No newline at end of file +} diff --git a/CleanAspCore/Features/Departments/GetDepartmentById.cs b/CleanAspCore/Features/Departments/GetDepartmentById.cs new file mode 100644 index 0000000..ade22f0 --- /dev/null +++ b/CleanAspCore/Features/Departments/GetDepartmentById.cs @@ -0,0 +1,19 @@ +using CleanAspCore.Data; +using CleanAspCore.Domain.Departments; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.EntityFrameworkCore; + +namespace CleanAspCore.Features.Departments; + +internal static class GetDepartmentById +{ + internal static async Task> Handle(Guid id, HrContext context, CancellationToken cancellationToken) + { + var department = await context.Departments + .Where(x => x.Id == id) + .Select(x => x.ToDto()) + .FirstAsync(cancellationToken); + + return TypedResults.Ok(department); + } +} diff --git a/CleanAspCore/Features/Departments/GetDepartments.cs b/CleanAspCore/Features/Departments/GetDepartments.cs deleted file mode 100644 index 725c232..0000000 --- a/CleanAspCore/Features/Departments/GetDepartments.cs +++ /dev/null @@ -1,32 +0,0 @@ -using CleanAspCore.Data; -using CleanAspCore.Domain.Departments; -using Microsoft.EntityFrameworkCore; - -namespace CleanAspCore.Features.Departments; - -public class GetDepartments : IRouteModule -{ - public void AddRoutes(IEndpointRouteBuilder endpoints) - { - endpoints.MapGet("Department", async (ISender sender) => TypedResults.Json(await sender.Send(new Request()))) - .WithTags("Department"); - } - - public record Request : IRequest>; - - public class Handler : IRequestHandler> - { - private readonly HrContext _context; - - public Handler(HrContext context) - { - _context = context; - } - - public async ValueTask> Handle(Request request, CancellationToken cancellationToken) => new(await - _context.Departments - .Select(x => x.ToDto()) - .AsNoTracking() - .ToListAsync(cancellationToken)); - } -} \ No newline at end of file diff --git a/CleanAspCore/Features/Employees/AddEmployee.cs b/CleanAspCore/Features/Employees/AddEmployee.cs index fc500a7..3d26fc0 100644 --- a/CleanAspCore/Features/Employees/AddEmployee.cs +++ b/CleanAspCore/Features/Employees/AddEmployee.cs @@ -4,27 +4,32 @@ namespace CleanAspCore.Features.Employees; -public class AddEmployee : IRouteModule +public class EmployeeDtoValidator : AbstractValidator { - public void AddRoutes(IEndpointRouteBuilder endpoints) + public EmployeeDtoValidator() { - endpoints.MapPost("Employee", PostEmployee) - .WithTags("Employee"); + RuleFor(x => x.FirstName).NotEmpty(); + RuleFor(x => x.LastName).NotEmpty(); + RuleFor(x => x.Email).EmailAddress().NotNull(); } +} - private static async Task, ValidationProblem>> PostEmployee( - [FromBody] EmployeeDto employeeDto, HrContext context, IValidator validator, CancellationToken cancellationToken) +internal static class AddEmployee +{ + internal static async Task> Handle([FromBody] EmployeeDto request, HrContext context, [FromServices] IValidator validator, + CancellationToken cancellationToken) { - var employee = employeeDto.ToDomain(); - var validationResult = await validator.ValidateAsync(employee, cancellationToken); + var validationResult = await validator.ValidateAsync(request, cancellationToken); if (!validationResult.IsValid) { return TypedResults.ValidationProblem(validationResult.ToDictionary()); } + var employee = request.ToDomain(); + context.Employees.AddRange(employee); await context.SaveChangesAsync(cancellationToken); - return TypedResults.Ok(employee.ToDto()); + return TypedResults.CreatedAtRoute(nameof(GetEmployeeById), new { employee.Id }); } } diff --git a/CleanAspCore/Features/Employees/DeleteEmployeeById.cs b/CleanAspCore/Features/Employees/DeleteEmployeeById.cs index abfb2a8..a98175b 100644 --- a/CleanAspCore/Features/Employees/DeleteEmployeeById.cs +++ b/CleanAspCore/Features/Employees/DeleteEmployeeById.cs @@ -4,15 +4,9 @@ namespace CleanAspCore.Features.Employees; -public class DeleteEmployeeById : IRouteModule +internal static class DeleteEmployeeById { - public void AddRoutes(IEndpointRouteBuilder endpoints) - { - endpoints.MapDelete("Employee/{id}", DeleteEmployee) - .WithTags("Employee"); - } - - private static async Task> DeleteEmployee(int id, HrContext context, CancellationToken cancellationToken) + internal static async Task> Handle(Guid id, HrContext context, CancellationToken cancellationToken) { var result = await context.Employees.Where(x => x.Id == id) .ExecuteDeleteAsync(cancellationToken); diff --git a/CleanAspCore/Features/Employees/GetEmployeeById.cs b/CleanAspCore/Features/Employees/GetEmployeeById.cs new file mode 100644 index 0000000..d3fe81e --- /dev/null +++ b/CleanAspCore/Features/Employees/GetEmployeeById.cs @@ -0,0 +1,18 @@ +using CleanAspCore.Data; +using CleanAspCore.Domain.Employees; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.EntityFrameworkCore; + +namespace CleanAspCore.Features.Employees; + +internal static class GetEmployeeById +{ + internal static async Task> Handle(Guid id, HrContext context, CancellationToken cancellationToken) + { + var result = await context.Employees + .Where(x => x.Id == id) + .Select(x => x.ToDto()) + .FirstAsync(cancellationToken); + return TypedResults.Json(result); + } +} diff --git a/CleanAspCore/Features/Employees/GetEmployees.cs b/CleanAspCore/Features/Employees/GetEmployees.cs deleted file mode 100644 index 64206d4..0000000 --- a/CleanAspCore/Features/Employees/GetEmployees.cs +++ /dev/null @@ -1,23 +0,0 @@ -using CleanAspCore.Data; -using CleanAspCore.Domain.Employees; -using Microsoft.AspNetCore.Http.HttpResults; -using Microsoft.EntityFrameworkCore; - -namespace CleanAspCore.Features.Employees; - -public class GetEmployees : IRouteModule -{ - public void AddRoutes(IEndpointRouteBuilder endpoints) - { - endpoints.MapGet("Employee", GetEmployee) - .WithTags("Employee"); - } - - private static async Task>> GetEmployee(HrContext context, CancellationToken cancellationToken) - { - var result = await context.Employees.Select(x => x.ToDto()) - .AsNoTracking() - .ToListAsync(cancellationToken); - return TypedResults.Json(result); - } -} diff --git a/CleanAspCore/Features/Employees/UpdateEmployeeById.cs b/CleanAspCore/Features/Employees/UpdateEmployeeById.cs index 3bff6fe..3d54dda 100644 --- a/CleanAspCore/Features/Employees/UpdateEmployeeById.cs +++ b/CleanAspCore/Features/Employees/UpdateEmployeeById.cs @@ -1,47 +1,95 @@ -using CleanAspCore.Data; +using System.Linq.Expressions; +using CleanAspCore.Data; using CleanAspCore.Domain.Employees; using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Query; using NotFound = Microsoft.AspNetCore.Http.HttpResults.NotFound; namespace CleanAspCore.Features.Employees; -public class UpdateEmployeeById : IRouteModule +public class UpdateEmployeeRequestValidator : AbstractValidator { - public void AddRoutes(IEndpointRouteBuilder endpoints) + public UpdateEmployeeRequestValidator() { - endpoints.MapPut("Employee", PutEmployee) - .WithTags("Employee"); + RuleFor(x => x.Email).EmailAddress(); } +} - private static async Task> PutEmployee( - [FromBody] EmployeeDto employeeDto, HrContext context, IValidator validator, CancellationToken cancellationToken) - { - var updatedEmployee = employeeDto.ToDomain(); +public sealed class UpdateEmployeeRequest +{ + public string? FirstName { get; init; } + public string? LastName { get; init; } + public string? Email { get; init; } + public string? Gender { get; init; } + public Guid? DepartmentId { get; init; } + public Guid? JobId { get; init; } +} - var validationResult = await validator.ValidateAsync(updatedEmployee, cancellationToken); +internal static class UpdateEmployeeById +{ + internal static async Task> Handle(Guid id, + [FromBody] UpdateEmployeeRequest updateEmployeeRequest, HrContext context, [FromServices] IValidator validator, CancellationToken cancellationToken) + { + var validationResult = await validator.ValidateAsync(updateEmployeeRequest, cancellationToken); if (!validationResult.IsValid) { return TypedResults.ValidationProblem(validationResult.ToDictionary()); } - var employee = context.Employees.FirstOrDefault(x => x.Id == updatedEmployee.Id); - if (employee != null) - { - employee.FirstName = updatedEmployee.FirstName; - employee.LastName = updatedEmployee.LastName; - employee.Email = updatedEmployee.Email; - employee.Gender = updatedEmployee.Gender; + var builder = new SetPropertyBuilder() + .SetPropertyIfNotNull(x => x.FirstName, updateEmployeeRequest.FirstName) + .SetPropertyIfNotNull(x => x.LastName, updateEmployeeRequest.LastName) + .SetPropertyIfNotNull(x => x.Email, updateEmployeeRequest.Email) + .SetPropertyIfNotNull(x => x.Gender, updateEmployeeRequest.Gender) + .SetPropertyIfNotNull(x => x.DepartmentId, updateEmployeeRequest.DepartmentId) + .SetPropertyIfNotNull(x => x.JobId, updateEmployeeRequest.JobId); - employee.DepartmentId = updatedEmployee.DepartmentId; - employee.JobId = updatedEmployee.JobId; + var changed = await context.Employees + .Where(x => x.Id == id) + .ExecuteUpdateAsync(builder.SetPropertyCalls, cancellationToken); - await context.SaveChangesAsync(cancellationToken); - return TypedResults.Ok(); - } - else + return changed switch { - return TypedResults.NotFound(); - } + 1 => TypedResults.NoContent(), + _ => TypedResults.NotFound() + }; + } +} + +public class SetPropertyBuilder +{ + public Expression, SetPropertyCalls>> SetPropertyCalls { get; private set; } = b => b; + + public SetPropertyBuilder SetProperty( + Expression> propertyExpression, + TProperty value + ) => SetProperty(propertyExpression, _ => value); + + public SetPropertyBuilder SetPropertyIfNotNull( + Expression> propertyExpression, + TProperty value + ) => value != null ? SetProperty(propertyExpression, _ => value) : this; + + public SetPropertyBuilder SetProperty( + Expression> propertyExpression, + Expression> valueExpression + ) + { + SetPropertyCalls = SetPropertyCalls.Update( + body: Expression.Call( + instance: SetPropertyCalls.Body, + methodName: nameof(SetPropertyCalls.SetProperty), + typeArguments: new[] { typeof(TProperty) }, + arguments: new Expression[] { + propertyExpression, + valueExpression + } + ), + parameters: SetPropertyCalls.Parameters + ); + + return this; } } diff --git a/CleanAspCore/Features/Import/DepartmentFaker.cs b/CleanAspCore/Features/Import/DepartmentFaker.cs new file mode 100644 index 0000000..b4d09e3 --- /dev/null +++ b/CleanAspCore/Features/Import/DepartmentFaker.cs @@ -0,0 +1,15 @@ +using Bogus; +using CleanAspCore.Domain.Departments; + +namespace CleanAspCore.Features.Import; + +public sealed class DepartmentFaker : Faker +{ + public DepartmentFaker() + { + UseSeed(2); + RuleFor(x => x.Id, f => f.Random.Guid()); + RuleFor(x => x.Name, f => f.Company.CompanyName()); + RuleFor(x => x.City, f => f.Address.City()); + } +} diff --git a/CleanAspCore/Features/Import/EmployeeFaker.cs b/CleanAspCore/Features/Import/EmployeeFaker.cs new file mode 100644 index 0000000..9b274d5 --- /dev/null +++ b/CleanAspCore/Features/Import/EmployeeFaker.cs @@ -0,0 +1,39 @@ +using Bogus; +using CleanAspCore.Domain.Employees; + +namespace CleanAspCore.Features.Import; + +public sealed class EmployeeFaker : Faker +{ + public EmployeeFaker() + { + UseSeed(3); + RuleFor(x => x.FirstName, f => f.Name.FirstName()); + RuleFor(x => x.LastName, f => f.Name.LastName()); + RuleFor(x => x.Email, f => new EmailAddress(f.Internet.Email())); + RuleFor(x => x.Gender, f => f.PickRandom("Male", "Female")); + RuleFor(x => x.Department, f => new DepartmentFaker().Generate()); + RuleFor(x => x.Job, f => new JobFaker().Generate()); + + FinishWith((x, y) => + { + y.DepartmentId = y.Department!.Id; + y.JobId = y.Job!.Id; + }); + } +} + +public sealed class EmployeeDtoFaker : Faker +{ + public EmployeeDtoFaker() + { + UseSeed(3); + RuleFor(x => x.FirstName, f => f.Name.FirstName()); + RuleFor(x => x.LastName, f => f.Name.LastName()); + RuleFor(x => x.Email, f => new EmailAddress(f.Internet.Email())); + RuleFor(x => x.Gender, f => f.PickRandom("Male", "Female")); + RuleFor(x => x.DepartmentId, f => f.Random.Guid()); + RuleFor(x => x.JobId, f => f.Random.Guid()); + + } +} diff --git a/CleanAspCore/Features/Import/Fakers.cs b/CleanAspCore/Features/Import/Fakers.cs deleted file mode 100644 index 1ec761d..0000000 --- a/CleanAspCore/Features/Import/Fakers.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Bogus; -using CleanAspCore.Domain.Departments; -using CleanAspCore.Domain.Employees; -using CleanAspCore.Domain.Jobs; - -namespace CleanAspCore.Features.Import; - -public class Fakers -{ - public static Faker CreateDepartmentFaker() => new Faker() - .UseSeed(2) - .RuleFor(x => x.Name, f => f.Company.CompanyName()) - .RuleFor(x => x.City, f => f.Address.City()); - - public static Faker CreateJobFaker() => new Faker() - .UseSeed(1) - .RuleFor(x => x.Name, f => f.Name.JobTitle()); - - public static Faker CreateEmployeeFaker(List? jobs = null, List? departments = null) - { - jobs ??= CreateJobFaker().Generate(1); - departments ??= CreateDepartmentFaker().Generate(1); - - var employeeFaker = new Faker() - .UseSeed(3) - .RuleFor(x => x.FirstName, f => f.Name.FirstName()) - .RuleFor(x => x.LastName, f => f.Name.LastName()) - .RuleFor(x => x.Email, f => new EmailAddress(f.Internet.Email())) - .RuleFor(x => x.Gender, f => f.PickRandom("Male", "Female")) - .RuleFor(x => x.Department, f => f.PickRandom(departments)) - .RuleFor(x => x.Job, f => f.PickRandom(jobs)); - - return employeeFaker; - } -} \ No newline at end of file diff --git a/CleanAspCore/Features/Import/ImportTestData.cs b/CleanAspCore/Features/Import/ImportTestData.cs index f7f781b..6509250 100644 --- a/CleanAspCore/Features/Import/ImportTestData.cs +++ b/CleanAspCore/Features/Import/ImportTestData.cs @@ -1,35 +1,35 @@ using CleanAspCore.Data; +using Microsoft.AspNetCore.Http.HttpResults; namespace CleanAspCore.Features.Import; -public class ImportTestData : IRouteModule +internal static class ImportTestData { - public void AddRoutes(IEndpointRouteBuilder endpoints) + public static async Task Handle(HrContext context, CancellationToken cancellationToken) { - endpoints.MapPut("Import", async (HrContext context, CancellationToken cancellationToken) => + var newJobs = new JobFaker().Generate(10); + foreach (var newJob in newJobs) { - var newJobs = Fakers.CreateJobFaker().Generate(10); - foreach (var newJob in newJobs) - { - context.Jobs.AddIfNotExists(newJob); - } + context.Jobs.AddIfNotExists(newJob); + } - var newDepartments = Fakers.CreateDepartmentFaker().Generate(5); - foreach (var newDepartment in newDepartments) - { - context.Departments.AddIfNotExists(newDepartment); - } + var newDepartments = new DepartmentFaker().Generate(5); + foreach (var newDepartment in newDepartments) + { + context.Departments.AddIfNotExists(newDepartment); + } - var newEmployees = Fakers.CreateEmployeeFaker(newJobs, newDepartments).Generate(100); - foreach (var newEmployee in newEmployees) - { - context.Employees.AddIfNotExists(newEmployee); - } + var newEmployees = new EmployeeFaker() + .RuleFor(x => x.Department, f => f.PickRandom(newDepartments)) + .RuleFor(x => x.Job, f => f.PickRandom(newJobs)) + .Generate(100); + foreach (var newEmployee in newEmployees) + { + context.Employees.AddIfNotExists(newEmployee); + } - await context.SaveChangesAsync(cancellationToken); + await context.SaveChangesAsync(cancellationToken); - return TypedResults.Ok(); - }) - .WithTags("Import"); + return TypedResults.Ok(); } } diff --git a/CleanAspCore/Features/Import/JobFaker.cs b/CleanAspCore/Features/Import/JobFaker.cs new file mode 100644 index 0000000..f6e2cd3 --- /dev/null +++ b/CleanAspCore/Features/Import/JobFaker.cs @@ -0,0 +1,14 @@ +using Bogus; +using CleanAspCore.Domain.Jobs; + +namespace CleanAspCore.Features.Import; + +public sealed class JobFaker : Faker +{ + public JobFaker() + { + UseSeed(1); + RuleFor(x => x.Id, f => f.Random.Guid()); + RuleFor(x => x.Name, f => f.Name.JobTitle()); + } +} diff --git a/CleanAspCore/Features/Jobs/AddJobs.cs b/CleanAspCore/Features/Jobs/AddJobs.cs index 0eb832e..df4a10c 100644 --- a/CleanAspCore/Features/Jobs/AddJobs.cs +++ b/CleanAspCore/Features/Jobs/AddJobs.cs @@ -5,18 +5,15 @@ namespace CleanAspCore.Features.Jobs; -public class AddJobs : IRouteModule +internal static class AddJobs { - public void AddRoutes(IEndpointRouteBuilder endpoints) + internal static async Task Handle([FromBody] JobDto createJobRequest, HrContext context, CancellationToken cancellationToken) { - endpoints.MapPost("Jobs", PostJobs) - .WithTags("Jobs"); - } + var job = createJobRequest.ToDomain(); - private static async Task PostJobs([FromBody] List jobs, HrContext context, IValidator validator, CancellationToken cancellationToken) - { - context.Jobs.AddRange(jobs); + context.Jobs.AddRange(job); await context.SaveChangesAsync(cancellationToken); - return TypedResults.Ok(); + + return TypedResults.CreatedAtRoute(nameof(GetJobById), new { job.Id }); } } diff --git a/CleanAspCore/Features/Jobs/GetJobById.cs b/CleanAspCore/Features/Jobs/GetJobById.cs new file mode 100644 index 0000000..fb113de --- /dev/null +++ b/CleanAspCore/Features/Jobs/GetJobById.cs @@ -0,0 +1,18 @@ +using CleanAspCore.Data; +using CleanAspCore.Domain.Jobs; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.EntityFrameworkCore; + +namespace CleanAspCore.Features.Jobs; + +internal static class GetJobById +{ + internal static async Task> Handle(Guid id, HrContext context, CancellationToken cancellationToken) + { + var results = await context.Jobs + .Where(x => x.Id == id) + .Select(x => x.ToDto()) + .FirstAsync(cancellationToken); + return TypedResults.Json(results); + } +} diff --git a/CleanAspCore/Features/Jobs/GetJobs.cs b/CleanAspCore/Features/Jobs/GetJobs.cs deleted file mode 100644 index fd036cd..0000000 --- a/CleanAspCore/Features/Jobs/GetJobs.cs +++ /dev/null @@ -1,23 +0,0 @@ -using CleanAspCore.Data; -using CleanAspCore.Domain.Jobs; -using Microsoft.AspNetCore.Http.HttpResults; -using Microsoft.EntityFrameworkCore; - -namespace CleanAspCore.Features.Jobs; - -public class GetJobs : IRouteModule -{ - public void AddRoutes(IEndpointRouteBuilder endpoints) - { - endpoints.MapGet("Job", GetAllJobs) - .WithTags("Job"); - } - - private static async Task>> GetAllJobs(HrContext context, CancellationToken cancellationToken) - { - var results = await context.Jobs.Select(x => x.ToDto()) - .AsNoTracking() - .ToListAsync(cancellationToken); - return TypedResults.Json(results); - } -} diff --git a/CleanAspCore/IRouteModule.cs b/CleanAspCore/IRouteModule.cs deleted file mode 100644 index af8cb08..0000000 --- a/CleanAspCore/IRouteModule.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace CleanAspCore; - -public interface IRouteModule -{ - void AddRoutes(IEndpointRouteBuilder endpoints); -} \ No newline at end of file diff --git a/CleanAspCore/Program.cs b/CleanAspCore/Program.cs index 3cd9f74..3cd533d 100644 --- a/CleanAspCore/Program.cs +++ b/CleanAspCore/Program.cs @@ -1,9 +1,13 @@ using System.Reflection; +using System.Runtime.CompilerServices; using CleanAspCore; using CleanAspCore.Data; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.FileProviders; + +[assembly: InternalsVisibleTo("CleanAspCore.Api.Tests")] + var builder = WebApplication.CreateBuilder(args); builder.Services.AddEndpointsApiExplorer(); @@ -11,13 +15,8 @@ builder.Services.AddAuthorization(); builder.Services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); builder.Services.AddDbContext(options => options.UseNpgsql(builder.Configuration.GetConnectionString("Default"))); -builder.Services.RegisterRouteModules(); -builder.Services.AddMediator(options => -{ - options.ServiceLifetime = ServiceLifetime.Transient; -}); -builder.Services.AddSingleton(new PhysicalFileProvider(Directory.GetCurrentDirectory())); +builder.Services.AddSingleton(new PhysicalFileProvider(Directory.GetCurrentDirectory())); var app = builder.Build(); @@ -36,8 +35,8 @@ app.UseHttpsRedirection(); app.UseAuthorization(); -app.AddRouteModules(); +app.AddRoutes(); app.Run(); -public partial class Program { } \ No newline at end of file +public partial class Program { } diff --git a/CleanAspCore/Routes.cs b/CleanAspCore/Routes.cs new file mode 100644 index 0000000..3b64d42 --- /dev/null +++ b/CleanAspCore/Routes.cs @@ -0,0 +1,32 @@ +using CleanAspCore.Features.Departments; +using CleanAspCore.Features.Employees; +using CleanAspCore.Features.Import; +using CleanAspCore.Features.Jobs; + +namespace CleanAspCore; + +public static class EndpointRouteBuilderExtensions +{ + public static void AddRoutes(this IEndpointRouteBuilder host) + { + var departmentGroup = host.MapGroup("/departments"); + departmentGroup.MapPost("/", AddDepartments.Handle); + departmentGroup.MapGet("/{id:guid}", GetDepartmentById.Handle) + .WithName(nameof(GetDepartmentById)); + + var employeeGroup = host.MapGroup("/employees"); + employeeGroup.MapPost("/", AddEmployee.Handle); + employeeGroup.MapGet("/{id:guid}", GetEmployeeById.Handle) + .WithName(nameof(GetEmployeeById)); + employeeGroup.MapDelete("/{id:guid}", DeleteEmployeeById.Handle); + employeeGroup.MapPut("/{id:guid}", UpdateEmployeeById.Handle); + + var jobGroup = host.MapGroup("/jobs"); + jobGroup.MapPost("/",AddJobs.Handle); + jobGroup.MapGet("/{id:guid}", GetJobById.Handle) + .WithName(nameof(GetJobById)); + + var importGroup = host.MapGroup("/import"); + importGroup.MapPut("/", ImportTestData.Handle); + } +} diff --git a/CleanAspCore/ServiceCollectionExtensions.cs b/CleanAspCore/ServiceCollectionExtensions.cs deleted file mode 100644 index 843b57d..0000000 --- a/CleanAspCore/ServiceCollectionExtensions.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace CleanAspCore; - -public static class ServiceCollectionExtensions -{ - public static void RegisterRouteModules(this IServiceCollection services) - { - services.Scan(scan => scan - .FromAssemblyOf() - .AddClasses(classes => classes.AssignableTo()) - .As()); - } -} \ No newline at end of file diff --git a/CleanAspCore/Usings.cs b/CleanAspCore/Usings.cs index 0083b5f..674f0d9 100644 --- a/CleanAspCore/Usings.cs +++ b/CleanAspCore/Usings.cs @@ -1,4 +1,3 @@ -global using Mediator; -global using Microsoft.AspNetCore.Mvc; +global using Microsoft.AspNetCore.Mvc; global using FluentValidation; global using CleanAspCore.Domain; diff --git a/global.json b/global.json index f75e913..657a04b 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "8.0.100-rc.2.23502.2", + "version": "8.0.204", "rollForward": "latestMinor", "allowPrerelease": true }