diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/AdvancedRestConceptsSeries.sln b/aspnetcore-webapi/AdvancedRestConceptsSeries/AdvancedRestConceptsSeries.sln new file mode 100644 index 000000000..cb6974a97 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/AdvancedRestConceptsSeries.sln @@ -0,0 +1,651 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StartingProject", "StartingProject", "{ABB2D577-535D-D48F-8A83-D0D3A05DDA5C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AccountOwnerServer", "StartingProject\AccountOwnerServer\AccountOwnerServer.csproj", "{818132FC-82E4-4E0C-8D9D-DB9FAA82EC17}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LoggerService", "StartingProject\LoggerService\LoggerService.csproj", "{762A4F83-200A-4784-939C-30DDCCD07542}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contracts", "StartingProject\Contracts\Contracts.csproj", "{F7C739C1-1C1A-45FC-A5CF-19C85F88C492}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Entities", "StartingProject\Entities\Entities.csproj", "{2A46F144-074D-406E-8891-218DF31A92FA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Repository", "StartingProject\Repository\Repository.csproj", "{5C5CC11C-846A-469B-A1C7-1CE3A9E53918}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Paging", "Paging", "{EC6922CA-170E-0A2A-CC7E-F73B34E70907}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AccountOwnerServer", "Paging\AccountOwnerServer\AccountOwnerServer.csproj", "{49DD4823-A00F-48CD-A374-088B6C8E8D7E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LoggerService", "Paging\LoggerService\LoggerService.csproj", "{431FAA2C-9E7B-452B-B39F-82B08746E065}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contracts", "Paging\Contracts\Contracts.csproj", "{3C452E09-F2E5-4B2A-BFB0-CCF0D61D5C33}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Entities", "Paging\Entities\Entities.csproj", "{F0FF9E38-F849-4807-81B7-8723A0D9935E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Repository", "Paging\Repository\Repository.csproj", "{10FA361F-FD9B-4BF2-BF17-3A1FCC203B9B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AccountOwnerServer.Tests", "Paging\AccountOwnerServer.Tests\AccountOwnerServer.Tests.csproj", "{5197E122-7973-4DFC-816D-5AB8F9F20D62}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Filtering", "Filtering", "{37512E4E-BED2-30A1-7091-F8823DB56BB0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AccountOwnerServer", "Filtering\AccountOwnerServer\AccountOwnerServer.csproj", "{066DBED8-9500-4EDD-AE69-9735F1A34DB5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LoggerService", "Filtering\LoggerService\LoggerService.csproj", "{0023B98F-FAA0-40DE-8047-37A57B72287B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contracts", "Filtering\Contracts\Contracts.csproj", "{9F334D4D-D520-4E36-A3A0-FCF17D5027FE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Entities", "Filtering\Entities\Entities.csproj", "{618D8E73-942A-4C6A-B6DF-C3E24377D1A6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Repository", "Filtering\Repository\Repository.csproj", "{0695060C-08C2-4AD7-9906-3D7F17AF85F8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AccountOwnerServer.Tests", "Filtering\AccountOwnerServer.Tests\AccountOwnerServer.Tests.csproj", "{3188FDDE-FBF2-43FD-A841-40766012C8A2}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Searching", "Searching", "{E2866E30-6F85-1E9C-4A7D-9D94AC184FFF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AccountOwnerServer", "Searching\AccountOwnerServer\AccountOwnerServer.csproj", "{8534D3A8-7CBE-42A0-BAAB-68F9D0BFFA0C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LoggerService", "Searching\LoggerService\LoggerService.csproj", "{BFFF4AD6-8508-465F-B087-4CEE919D5909}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contracts", "Searching\Contracts\Contracts.csproj", "{927D22CA-7788-4035-A400-8B3F2CAEA650}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Entities", "Searching\Entities\Entities.csproj", "{D40EBE29-0DF7-494A-9B64-84CA9FC4D87B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Repository", "Searching\Repository\Repository.csproj", "{17F76803-025A-41A1-B91A-9E2B70CCF70B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AccountOwnerServer.Tests", "Searching\AccountOwnerServer.Tests\AccountOwnerServer.Tests.csproj", "{0F702691-51BD-4F55-9818-AA5E7616B507}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Sorting", "Sorting", "{05210415-73DC-ACC3-ABDB-6AAB38F7838E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AccountOwnerServer", "Sorting\AccountOwnerServer\AccountOwnerServer.csproj", "{A12941F8-AA71-477F-AC5F-37FD66253807}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LoggerService", "Sorting\LoggerService\LoggerService.csproj", "{FEB14BF3-536E-467C-930E-3F253B90A145}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contracts", "Sorting\Contracts\Contracts.csproj", "{9E6C8A4F-F911-4F9E-8E2F-2583C792A800}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Entities", "Sorting\Entities\Entities.csproj", "{445C10D4-F39E-4048-BA13-28FBFB9DCD63}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Repository", "Sorting\Repository\Repository.csproj", "{7E02B4A7-C232-4B63-B56D-38683D659FDE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AccountOwnerServer.Tests", "Sorting\AccountOwnerServer.Tests\AccountOwnerServer.Tests.csproj", "{3B2C28CD-FB49-44D9-9FC4-76A71F815FC4}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "DataShaping", "DataShaping", "{06DC3261-A065-1F52-480A-5354B826AC99}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AccountOwnerServer", "DataShaping\AccountOwnerServer\AccountOwnerServer.csproj", "{F787C38F-16C2-4EDE-9D28-E0EDE13215B3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LoggerService", "DataShaping\LoggerService\LoggerService.csproj", "{1EDCE8B0-14B1-4C65-9619-92A3D88FC587}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contracts", "DataShaping\Contracts\Contracts.csproj", "{E2EB8A87-973D-444F-81E4-2BA34B3CD32D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Entities", "DataShaping\Entities\Entities.csproj", "{0901A59F-AE0F-4B55-98B0-05FF23387FF7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Repository", "DataShaping\Repository\Repository.csproj", "{0AE788C5-4205-4A07-A1AA-6A0A32AF72F3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AccountOwnerServer.Tests", "DataShaping\AccountOwnerServer.Tests\AccountOwnerServer.Tests.csproj", "{F808372B-E577-4632-A733-A6BBEFAE3FE7}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Hateoas", "Hateoas", "{442FB6B7-7A89-545F-4529-C1729FE94F9C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AccountOwnerServer", "Hateoas\AccountOwnerServer\AccountOwnerServer.csproj", "{1A6DE1F4-AB77-4AA6-8EE7-080F918F1F60}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LoggerService", "Hateoas\LoggerService\LoggerService.csproj", "{1124B5DC-D042-4396-88E2-C1DB42ED198C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contracts", "Hateoas\Contracts\Contracts.csproj", "{069467F4-AD60-4098-82CE-C09304303F4E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Entities", "Hateoas\Entities\Entities.csproj", "{90BD1FE2-AD58-41F3-A351-612E167708FF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Repository", "Hateoas\Repository\Repository.csproj", "{EE529C09-917B-4FC0-AF19-0092B954F300}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AccountOwnerServer.Tests", "Hateoas\AccountOwnerServer.Tests\AccountOwnerServer.Tests.csproj", "{1B039528-384B-414A-B2C0-61738DC8CAA8}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {818132FC-82E4-4E0C-8D9D-DB9FAA82EC17}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {818132FC-82E4-4E0C-8D9D-DB9FAA82EC17}.Debug|Any CPU.Build.0 = Debug|Any CPU + {818132FC-82E4-4E0C-8D9D-DB9FAA82EC17}.Debug|x64.ActiveCfg = Debug|Any CPU + {818132FC-82E4-4E0C-8D9D-DB9FAA82EC17}.Debug|x64.Build.0 = Debug|Any CPU + {818132FC-82E4-4E0C-8D9D-DB9FAA82EC17}.Debug|x86.ActiveCfg = Debug|Any CPU + {818132FC-82E4-4E0C-8D9D-DB9FAA82EC17}.Debug|x86.Build.0 = Debug|Any CPU + {818132FC-82E4-4E0C-8D9D-DB9FAA82EC17}.Release|Any CPU.ActiveCfg = Release|Any CPU + {818132FC-82E4-4E0C-8D9D-DB9FAA82EC17}.Release|Any CPU.Build.0 = Release|Any CPU + {818132FC-82E4-4E0C-8D9D-DB9FAA82EC17}.Release|x64.ActiveCfg = Release|Any CPU + {818132FC-82E4-4E0C-8D9D-DB9FAA82EC17}.Release|x64.Build.0 = Release|Any CPU + {818132FC-82E4-4E0C-8D9D-DB9FAA82EC17}.Release|x86.ActiveCfg = Release|Any CPU + {818132FC-82E4-4E0C-8D9D-DB9FAA82EC17}.Release|x86.Build.0 = Release|Any CPU + {762A4F83-200A-4784-939C-30DDCCD07542}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {762A4F83-200A-4784-939C-30DDCCD07542}.Debug|Any CPU.Build.0 = Debug|Any CPU + {762A4F83-200A-4784-939C-30DDCCD07542}.Debug|x64.ActiveCfg = Debug|Any CPU + {762A4F83-200A-4784-939C-30DDCCD07542}.Debug|x64.Build.0 = Debug|Any CPU + {762A4F83-200A-4784-939C-30DDCCD07542}.Debug|x86.ActiveCfg = Debug|Any CPU + {762A4F83-200A-4784-939C-30DDCCD07542}.Debug|x86.Build.0 = Debug|Any CPU + {762A4F83-200A-4784-939C-30DDCCD07542}.Release|Any CPU.ActiveCfg = Release|Any CPU + {762A4F83-200A-4784-939C-30DDCCD07542}.Release|Any CPU.Build.0 = Release|Any CPU + {762A4F83-200A-4784-939C-30DDCCD07542}.Release|x64.ActiveCfg = Release|Any CPU + {762A4F83-200A-4784-939C-30DDCCD07542}.Release|x64.Build.0 = Release|Any CPU + {762A4F83-200A-4784-939C-30DDCCD07542}.Release|x86.ActiveCfg = Release|Any CPU + {762A4F83-200A-4784-939C-30DDCCD07542}.Release|x86.Build.0 = Release|Any CPU + {F7C739C1-1C1A-45FC-A5CF-19C85F88C492}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F7C739C1-1C1A-45FC-A5CF-19C85F88C492}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F7C739C1-1C1A-45FC-A5CF-19C85F88C492}.Debug|x64.ActiveCfg = Debug|Any CPU + {F7C739C1-1C1A-45FC-A5CF-19C85F88C492}.Debug|x64.Build.0 = Debug|Any CPU + {F7C739C1-1C1A-45FC-A5CF-19C85F88C492}.Debug|x86.ActiveCfg = Debug|Any CPU + {F7C739C1-1C1A-45FC-A5CF-19C85F88C492}.Debug|x86.Build.0 = Debug|Any CPU + {F7C739C1-1C1A-45FC-A5CF-19C85F88C492}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F7C739C1-1C1A-45FC-A5CF-19C85F88C492}.Release|Any CPU.Build.0 = Release|Any CPU + {F7C739C1-1C1A-45FC-A5CF-19C85F88C492}.Release|x64.ActiveCfg = Release|Any CPU + {F7C739C1-1C1A-45FC-A5CF-19C85F88C492}.Release|x64.Build.0 = Release|Any CPU + {F7C739C1-1C1A-45FC-A5CF-19C85F88C492}.Release|x86.ActiveCfg = Release|Any CPU + {F7C739C1-1C1A-45FC-A5CF-19C85F88C492}.Release|x86.Build.0 = Release|Any CPU + {2A46F144-074D-406E-8891-218DF31A92FA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2A46F144-074D-406E-8891-218DF31A92FA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2A46F144-074D-406E-8891-218DF31A92FA}.Debug|x64.ActiveCfg = Debug|Any CPU + {2A46F144-074D-406E-8891-218DF31A92FA}.Debug|x64.Build.0 = Debug|Any CPU + {2A46F144-074D-406E-8891-218DF31A92FA}.Debug|x86.ActiveCfg = Debug|Any CPU + {2A46F144-074D-406E-8891-218DF31A92FA}.Debug|x86.Build.0 = Debug|Any CPU + {2A46F144-074D-406E-8891-218DF31A92FA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2A46F144-074D-406E-8891-218DF31A92FA}.Release|Any CPU.Build.0 = Release|Any CPU + {2A46F144-074D-406E-8891-218DF31A92FA}.Release|x64.ActiveCfg = Release|Any CPU + {2A46F144-074D-406E-8891-218DF31A92FA}.Release|x64.Build.0 = Release|Any CPU + {2A46F144-074D-406E-8891-218DF31A92FA}.Release|x86.ActiveCfg = Release|Any CPU + {2A46F144-074D-406E-8891-218DF31A92FA}.Release|x86.Build.0 = Release|Any CPU + {5C5CC11C-846A-469B-A1C7-1CE3A9E53918}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5C5CC11C-846A-469B-A1C7-1CE3A9E53918}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5C5CC11C-846A-469B-A1C7-1CE3A9E53918}.Debug|x64.ActiveCfg = Debug|Any CPU + {5C5CC11C-846A-469B-A1C7-1CE3A9E53918}.Debug|x64.Build.0 = Debug|Any CPU + {5C5CC11C-846A-469B-A1C7-1CE3A9E53918}.Debug|x86.ActiveCfg = Debug|Any CPU + {5C5CC11C-846A-469B-A1C7-1CE3A9E53918}.Debug|x86.Build.0 = Debug|Any CPU + {5C5CC11C-846A-469B-A1C7-1CE3A9E53918}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5C5CC11C-846A-469B-A1C7-1CE3A9E53918}.Release|Any CPU.Build.0 = Release|Any CPU + {5C5CC11C-846A-469B-A1C7-1CE3A9E53918}.Release|x64.ActiveCfg = Release|Any CPU + {5C5CC11C-846A-469B-A1C7-1CE3A9E53918}.Release|x64.Build.0 = Release|Any CPU + {5C5CC11C-846A-469B-A1C7-1CE3A9E53918}.Release|x86.ActiveCfg = Release|Any CPU + {5C5CC11C-846A-469B-A1C7-1CE3A9E53918}.Release|x86.Build.0 = Release|Any CPU + {49DD4823-A00F-48CD-A374-088B6C8E8D7E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {49DD4823-A00F-48CD-A374-088B6C8E8D7E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {49DD4823-A00F-48CD-A374-088B6C8E8D7E}.Debug|x64.ActiveCfg = Debug|Any CPU + {49DD4823-A00F-48CD-A374-088B6C8E8D7E}.Debug|x64.Build.0 = Debug|Any CPU + {49DD4823-A00F-48CD-A374-088B6C8E8D7E}.Debug|x86.ActiveCfg = Debug|Any CPU + {49DD4823-A00F-48CD-A374-088B6C8E8D7E}.Debug|x86.Build.0 = Debug|Any CPU + {49DD4823-A00F-48CD-A374-088B6C8E8D7E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {49DD4823-A00F-48CD-A374-088B6C8E8D7E}.Release|Any CPU.Build.0 = Release|Any CPU + {49DD4823-A00F-48CD-A374-088B6C8E8D7E}.Release|x64.ActiveCfg = Release|Any CPU + {49DD4823-A00F-48CD-A374-088B6C8E8D7E}.Release|x64.Build.0 = Release|Any CPU + {49DD4823-A00F-48CD-A374-088B6C8E8D7E}.Release|x86.ActiveCfg = Release|Any CPU + {49DD4823-A00F-48CD-A374-088B6C8E8D7E}.Release|x86.Build.0 = Release|Any CPU + {431FAA2C-9E7B-452B-B39F-82B08746E065}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {431FAA2C-9E7B-452B-B39F-82B08746E065}.Debug|Any CPU.Build.0 = Debug|Any CPU + {431FAA2C-9E7B-452B-B39F-82B08746E065}.Debug|x64.ActiveCfg = Debug|Any CPU + {431FAA2C-9E7B-452B-B39F-82B08746E065}.Debug|x64.Build.0 = Debug|Any CPU + {431FAA2C-9E7B-452B-B39F-82B08746E065}.Debug|x86.ActiveCfg = Debug|Any CPU + {431FAA2C-9E7B-452B-B39F-82B08746E065}.Debug|x86.Build.0 = Debug|Any CPU + {431FAA2C-9E7B-452B-B39F-82B08746E065}.Release|Any CPU.ActiveCfg = Release|Any CPU + {431FAA2C-9E7B-452B-B39F-82B08746E065}.Release|Any CPU.Build.0 = Release|Any CPU + {431FAA2C-9E7B-452B-B39F-82B08746E065}.Release|x64.ActiveCfg = Release|Any CPU + {431FAA2C-9E7B-452B-B39F-82B08746E065}.Release|x64.Build.0 = Release|Any CPU + {431FAA2C-9E7B-452B-B39F-82B08746E065}.Release|x86.ActiveCfg = Release|Any CPU + {431FAA2C-9E7B-452B-B39F-82B08746E065}.Release|x86.Build.0 = Release|Any CPU + {3C452E09-F2E5-4B2A-BFB0-CCF0D61D5C33}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3C452E09-F2E5-4B2A-BFB0-CCF0D61D5C33}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3C452E09-F2E5-4B2A-BFB0-CCF0D61D5C33}.Debug|x64.ActiveCfg = Debug|Any CPU + {3C452E09-F2E5-4B2A-BFB0-CCF0D61D5C33}.Debug|x64.Build.0 = Debug|Any CPU + {3C452E09-F2E5-4B2A-BFB0-CCF0D61D5C33}.Debug|x86.ActiveCfg = Debug|Any CPU + {3C452E09-F2E5-4B2A-BFB0-CCF0D61D5C33}.Debug|x86.Build.0 = Debug|Any CPU + {3C452E09-F2E5-4B2A-BFB0-CCF0D61D5C33}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3C452E09-F2E5-4B2A-BFB0-CCF0D61D5C33}.Release|Any CPU.Build.0 = Release|Any CPU + {3C452E09-F2E5-4B2A-BFB0-CCF0D61D5C33}.Release|x64.ActiveCfg = Release|Any CPU + {3C452E09-F2E5-4B2A-BFB0-CCF0D61D5C33}.Release|x64.Build.0 = Release|Any CPU + {3C452E09-F2E5-4B2A-BFB0-CCF0D61D5C33}.Release|x86.ActiveCfg = Release|Any CPU + {3C452E09-F2E5-4B2A-BFB0-CCF0D61D5C33}.Release|x86.Build.0 = Release|Any CPU + {F0FF9E38-F849-4807-81B7-8723A0D9935E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F0FF9E38-F849-4807-81B7-8723A0D9935E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F0FF9E38-F849-4807-81B7-8723A0D9935E}.Debug|x64.ActiveCfg = Debug|Any CPU + {F0FF9E38-F849-4807-81B7-8723A0D9935E}.Debug|x64.Build.0 = Debug|Any CPU + {F0FF9E38-F849-4807-81B7-8723A0D9935E}.Debug|x86.ActiveCfg = Debug|Any CPU + {F0FF9E38-F849-4807-81B7-8723A0D9935E}.Debug|x86.Build.0 = Debug|Any CPU + {F0FF9E38-F849-4807-81B7-8723A0D9935E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F0FF9E38-F849-4807-81B7-8723A0D9935E}.Release|Any CPU.Build.0 = Release|Any CPU + {F0FF9E38-F849-4807-81B7-8723A0D9935E}.Release|x64.ActiveCfg = Release|Any CPU + {F0FF9E38-F849-4807-81B7-8723A0D9935E}.Release|x64.Build.0 = Release|Any CPU + {F0FF9E38-F849-4807-81B7-8723A0D9935E}.Release|x86.ActiveCfg = Release|Any CPU + {F0FF9E38-F849-4807-81B7-8723A0D9935E}.Release|x86.Build.0 = Release|Any CPU + {10FA361F-FD9B-4BF2-BF17-3A1FCC203B9B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {10FA361F-FD9B-4BF2-BF17-3A1FCC203B9B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {10FA361F-FD9B-4BF2-BF17-3A1FCC203B9B}.Debug|x64.ActiveCfg = Debug|Any CPU + {10FA361F-FD9B-4BF2-BF17-3A1FCC203B9B}.Debug|x64.Build.0 = Debug|Any CPU + {10FA361F-FD9B-4BF2-BF17-3A1FCC203B9B}.Debug|x86.ActiveCfg = Debug|Any CPU + {10FA361F-FD9B-4BF2-BF17-3A1FCC203B9B}.Debug|x86.Build.0 = Debug|Any CPU + {10FA361F-FD9B-4BF2-BF17-3A1FCC203B9B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {10FA361F-FD9B-4BF2-BF17-3A1FCC203B9B}.Release|Any CPU.Build.0 = Release|Any CPU + {10FA361F-FD9B-4BF2-BF17-3A1FCC203B9B}.Release|x64.ActiveCfg = Release|Any CPU + {10FA361F-FD9B-4BF2-BF17-3A1FCC203B9B}.Release|x64.Build.0 = Release|Any CPU + {10FA361F-FD9B-4BF2-BF17-3A1FCC203B9B}.Release|x86.ActiveCfg = Release|Any CPU + {10FA361F-FD9B-4BF2-BF17-3A1FCC203B9B}.Release|x86.Build.0 = Release|Any CPU + {5197E122-7973-4DFC-816D-5AB8F9F20D62}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5197E122-7973-4DFC-816D-5AB8F9F20D62}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5197E122-7973-4DFC-816D-5AB8F9F20D62}.Debug|x64.ActiveCfg = Debug|Any CPU + {5197E122-7973-4DFC-816D-5AB8F9F20D62}.Debug|x64.Build.0 = Debug|Any CPU + {5197E122-7973-4DFC-816D-5AB8F9F20D62}.Debug|x86.ActiveCfg = Debug|Any CPU + {5197E122-7973-4DFC-816D-5AB8F9F20D62}.Debug|x86.Build.0 = Debug|Any CPU + {5197E122-7973-4DFC-816D-5AB8F9F20D62}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5197E122-7973-4DFC-816D-5AB8F9F20D62}.Release|Any CPU.Build.0 = Release|Any CPU + {5197E122-7973-4DFC-816D-5AB8F9F20D62}.Release|x64.ActiveCfg = Release|Any CPU + {5197E122-7973-4DFC-816D-5AB8F9F20D62}.Release|x64.Build.0 = Release|Any CPU + {5197E122-7973-4DFC-816D-5AB8F9F20D62}.Release|x86.ActiveCfg = Release|Any CPU + {5197E122-7973-4DFC-816D-5AB8F9F20D62}.Release|x86.Build.0 = Release|Any CPU + {066DBED8-9500-4EDD-AE69-9735F1A34DB5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {066DBED8-9500-4EDD-AE69-9735F1A34DB5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {066DBED8-9500-4EDD-AE69-9735F1A34DB5}.Debug|x64.ActiveCfg = Debug|Any CPU + {066DBED8-9500-4EDD-AE69-9735F1A34DB5}.Debug|x64.Build.0 = Debug|Any CPU + {066DBED8-9500-4EDD-AE69-9735F1A34DB5}.Debug|x86.ActiveCfg = Debug|Any CPU + {066DBED8-9500-4EDD-AE69-9735F1A34DB5}.Debug|x86.Build.0 = Debug|Any CPU + {066DBED8-9500-4EDD-AE69-9735F1A34DB5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {066DBED8-9500-4EDD-AE69-9735F1A34DB5}.Release|Any CPU.Build.0 = Release|Any CPU + {066DBED8-9500-4EDD-AE69-9735F1A34DB5}.Release|x64.ActiveCfg = Release|Any CPU + {066DBED8-9500-4EDD-AE69-9735F1A34DB5}.Release|x64.Build.0 = Release|Any CPU + {066DBED8-9500-4EDD-AE69-9735F1A34DB5}.Release|x86.ActiveCfg = Release|Any CPU + {066DBED8-9500-4EDD-AE69-9735F1A34DB5}.Release|x86.Build.0 = Release|Any CPU + {0023B98F-FAA0-40DE-8047-37A57B72287B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0023B98F-FAA0-40DE-8047-37A57B72287B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0023B98F-FAA0-40DE-8047-37A57B72287B}.Debug|x64.ActiveCfg = Debug|Any CPU + {0023B98F-FAA0-40DE-8047-37A57B72287B}.Debug|x64.Build.0 = Debug|Any CPU + {0023B98F-FAA0-40DE-8047-37A57B72287B}.Debug|x86.ActiveCfg = Debug|Any CPU + {0023B98F-FAA0-40DE-8047-37A57B72287B}.Debug|x86.Build.0 = Debug|Any CPU + {0023B98F-FAA0-40DE-8047-37A57B72287B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0023B98F-FAA0-40DE-8047-37A57B72287B}.Release|Any CPU.Build.0 = Release|Any CPU + {0023B98F-FAA0-40DE-8047-37A57B72287B}.Release|x64.ActiveCfg = Release|Any CPU + {0023B98F-FAA0-40DE-8047-37A57B72287B}.Release|x64.Build.0 = Release|Any CPU + {0023B98F-FAA0-40DE-8047-37A57B72287B}.Release|x86.ActiveCfg = Release|Any CPU + {0023B98F-FAA0-40DE-8047-37A57B72287B}.Release|x86.Build.0 = Release|Any CPU + {9F334D4D-D520-4E36-A3A0-FCF17D5027FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9F334D4D-D520-4E36-A3A0-FCF17D5027FE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9F334D4D-D520-4E36-A3A0-FCF17D5027FE}.Debug|x64.ActiveCfg = Debug|Any CPU + {9F334D4D-D520-4E36-A3A0-FCF17D5027FE}.Debug|x64.Build.0 = Debug|Any CPU + {9F334D4D-D520-4E36-A3A0-FCF17D5027FE}.Debug|x86.ActiveCfg = Debug|Any CPU + {9F334D4D-D520-4E36-A3A0-FCF17D5027FE}.Debug|x86.Build.0 = Debug|Any CPU + {9F334D4D-D520-4E36-A3A0-FCF17D5027FE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9F334D4D-D520-4E36-A3A0-FCF17D5027FE}.Release|Any CPU.Build.0 = Release|Any CPU + {9F334D4D-D520-4E36-A3A0-FCF17D5027FE}.Release|x64.ActiveCfg = Release|Any CPU + {9F334D4D-D520-4E36-A3A0-FCF17D5027FE}.Release|x64.Build.0 = Release|Any CPU + {9F334D4D-D520-4E36-A3A0-FCF17D5027FE}.Release|x86.ActiveCfg = Release|Any CPU + {9F334D4D-D520-4E36-A3A0-FCF17D5027FE}.Release|x86.Build.0 = Release|Any CPU + {618D8E73-942A-4C6A-B6DF-C3E24377D1A6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {618D8E73-942A-4C6A-B6DF-C3E24377D1A6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {618D8E73-942A-4C6A-B6DF-C3E24377D1A6}.Debug|x64.ActiveCfg = Debug|Any CPU + {618D8E73-942A-4C6A-B6DF-C3E24377D1A6}.Debug|x64.Build.0 = Debug|Any CPU + {618D8E73-942A-4C6A-B6DF-C3E24377D1A6}.Debug|x86.ActiveCfg = Debug|Any CPU + {618D8E73-942A-4C6A-B6DF-C3E24377D1A6}.Debug|x86.Build.0 = Debug|Any CPU + {618D8E73-942A-4C6A-B6DF-C3E24377D1A6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {618D8E73-942A-4C6A-B6DF-C3E24377D1A6}.Release|Any CPU.Build.0 = Release|Any CPU + {618D8E73-942A-4C6A-B6DF-C3E24377D1A6}.Release|x64.ActiveCfg = Release|Any CPU + {618D8E73-942A-4C6A-B6DF-C3E24377D1A6}.Release|x64.Build.0 = Release|Any CPU + {618D8E73-942A-4C6A-B6DF-C3E24377D1A6}.Release|x86.ActiveCfg = Release|Any CPU + {618D8E73-942A-4C6A-B6DF-C3E24377D1A6}.Release|x86.Build.0 = Release|Any CPU + {0695060C-08C2-4AD7-9906-3D7F17AF85F8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0695060C-08C2-4AD7-9906-3D7F17AF85F8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0695060C-08C2-4AD7-9906-3D7F17AF85F8}.Debug|x64.ActiveCfg = Debug|Any CPU + {0695060C-08C2-4AD7-9906-3D7F17AF85F8}.Debug|x64.Build.0 = Debug|Any CPU + {0695060C-08C2-4AD7-9906-3D7F17AF85F8}.Debug|x86.ActiveCfg = Debug|Any CPU + {0695060C-08C2-4AD7-9906-3D7F17AF85F8}.Debug|x86.Build.0 = Debug|Any CPU + {0695060C-08C2-4AD7-9906-3D7F17AF85F8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0695060C-08C2-4AD7-9906-3D7F17AF85F8}.Release|Any CPU.Build.0 = Release|Any CPU + {0695060C-08C2-4AD7-9906-3D7F17AF85F8}.Release|x64.ActiveCfg = Release|Any CPU + {0695060C-08C2-4AD7-9906-3D7F17AF85F8}.Release|x64.Build.0 = Release|Any CPU + {0695060C-08C2-4AD7-9906-3D7F17AF85F8}.Release|x86.ActiveCfg = Release|Any CPU + {0695060C-08C2-4AD7-9906-3D7F17AF85F8}.Release|x86.Build.0 = Release|Any CPU + {3188FDDE-FBF2-43FD-A841-40766012C8A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3188FDDE-FBF2-43FD-A841-40766012C8A2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3188FDDE-FBF2-43FD-A841-40766012C8A2}.Debug|x64.ActiveCfg = Debug|Any CPU + {3188FDDE-FBF2-43FD-A841-40766012C8A2}.Debug|x64.Build.0 = Debug|Any CPU + {3188FDDE-FBF2-43FD-A841-40766012C8A2}.Debug|x86.ActiveCfg = Debug|Any CPU + {3188FDDE-FBF2-43FD-A841-40766012C8A2}.Debug|x86.Build.0 = Debug|Any CPU + {3188FDDE-FBF2-43FD-A841-40766012C8A2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3188FDDE-FBF2-43FD-A841-40766012C8A2}.Release|Any CPU.Build.0 = Release|Any CPU + {3188FDDE-FBF2-43FD-A841-40766012C8A2}.Release|x64.ActiveCfg = Release|Any CPU + {3188FDDE-FBF2-43FD-A841-40766012C8A2}.Release|x64.Build.0 = Release|Any CPU + {3188FDDE-FBF2-43FD-A841-40766012C8A2}.Release|x86.ActiveCfg = Release|Any CPU + {3188FDDE-FBF2-43FD-A841-40766012C8A2}.Release|x86.Build.0 = Release|Any CPU + {8534D3A8-7CBE-42A0-BAAB-68F9D0BFFA0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8534D3A8-7CBE-42A0-BAAB-68F9D0BFFA0C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8534D3A8-7CBE-42A0-BAAB-68F9D0BFFA0C}.Debug|x64.ActiveCfg = Debug|Any CPU + {8534D3A8-7CBE-42A0-BAAB-68F9D0BFFA0C}.Debug|x64.Build.0 = Debug|Any CPU + {8534D3A8-7CBE-42A0-BAAB-68F9D0BFFA0C}.Debug|x86.ActiveCfg = Debug|Any CPU + {8534D3A8-7CBE-42A0-BAAB-68F9D0BFFA0C}.Debug|x86.Build.0 = Debug|Any CPU + {8534D3A8-7CBE-42A0-BAAB-68F9D0BFFA0C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8534D3A8-7CBE-42A0-BAAB-68F9D0BFFA0C}.Release|Any CPU.Build.0 = Release|Any CPU + {8534D3A8-7CBE-42A0-BAAB-68F9D0BFFA0C}.Release|x64.ActiveCfg = Release|Any CPU + {8534D3A8-7CBE-42A0-BAAB-68F9D0BFFA0C}.Release|x64.Build.0 = Release|Any CPU + {8534D3A8-7CBE-42A0-BAAB-68F9D0BFFA0C}.Release|x86.ActiveCfg = Release|Any CPU + {8534D3A8-7CBE-42A0-BAAB-68F9D0BFFA0C}.Release|x86.Build.0 = Release|Any CPU + {BFFF4AD6-8508-465F-B087-4CEE919D5909}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BFFF4AD6-8508-465F-B087-4CEE919D5909}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BFFF4AD6-8508-465F-B087-4CEE919D5909}.Debug|x64.ActiveCfg = Debug|Any CPU + {BFFF4AD6-8508-465F-B087-4CEE919D5909}.Debug|x64.Build.0 = Debug|Any CPU + {BFFF4AD6-8508-465F-B087-4CEE919D5909}.Debug|x86.ActiveCfg = Debug|Any CPU + {BFFF4AD6-8508-465F-B087-4CEE919D5909}.Debug|x86.Build.0 = Debug|Any CPU + {BFFF4AD6-8508-465F-B087-4CEE919D5909}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BFFF4AD6-8508-465F-B087-4CEE919D5909}.Release|Any CPU.Build.0 = Release|Any CPU + {BFFF4AD6-8508-465F-B087-4CEE919D5909}.Release|x64.ActiveCfg = Release|Any CPU + {BFFF4AD6-8508-465F-B087-4CEE919D5909}.Release|x64.Build.0 = Release|Any CPU + {BFFF4AD6-8508-465F-B087-4CEE919D5909}.Release|x86.ActiveCfg = Release|Any CPU + {BFFF4AD6-8508-465F-B087-4CEE919D5909}.Release|x86.Build.0 = Release|Any CPU + {927D22CA-7788-4035-A400-8B3F2CAEA650}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {927D22CA-7788-4035-A400-8B3F2CAEA650}.Debug|Any CPU.Build.0 = Debug|Any CPU + {927D22CA-7788-4035-A400-8B3F2CAEA650}.Debug|x64.ActiveCfg = Debug|Any CPU + {927D22CA-7788-4035-A400-8B3F2CAEA650}.Debug|x64.Build.0 = Debug|Any CPU + {927D22CA-7788-4035-A400-8B3F2CAEA650}.Debug|x86.ActiveCfg = Debug|Any CPU + {927D22CA-7788-4035-A400-8B3F2CAEA650}.Debug|x86.Build.0 = Debug|Any CPU + {927D22CA-7788-4035-A400-8B3F2CAEA650}.Release|Any CPU.ActiveCfg = Release|Any CPU + {927D22CA-7788-4035-A400-8B3F2CAEA650}.Release|Any CPU.Build.0 = Release|Any CPU + {927D22CA-7788-4035-A400-8B3F2CAEA650}.Release|x64.ActiveCfg = Release|Any CPU + {927D22CA-7788-4035-A400-8B3F2CAEA650}.Release|x64.Build.0 = Release|Any CPU + {927D22CA-7788-4035-A400-8B3F2CAEA650}.Release|x86.ActiveCfg = Release|Any CPU + {927D22CA-7788-4035-A400-8B3F2CAEA650}.Release|x86.Build.0 = Release|Any CPU + {D40EBE29-0DF7-494A-9B64-84CA9FC4D87B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D40EBE29-0DF7-494A-9B64-84CA9FC4D87B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D40EBE29-0DF7-494A-9B64-84CA9FC4D87B}.Debug|x64.ActiveCfg = Debug|Any CPU + {D40EBE29-0DF7-494A-9B64-84CA9FC4D87B}.Debug|x64.Build.0 = Debug|Any CPU + {D40EBE29-0DF7-494A-9B64-84CA9FC4D87B}.Debug|x86.ActiveCfg = Debug|Any CPU + {D40EBE29-0DF7-494A-9B64-84CA9FC4D87B}.Debug|x86.Build.0 = Debug|Any CPU + {D40EBE29-0DF7-494A-9B64-84CA9FC4D87B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D40EBE29-0DF7-494A-9B64-84CA9FC4D87B}.Release|Any CPU.Build.0 = Release|Any CPU + {D40EBE29-0DF7-494A-9B64-84CA9FC4D87B}.Release|x64.ActiveCfg = Release|Any CPU + {D40EBE29-0DF7-494A-9B64-84CA9FC4D87B}.Release|x64.Build.0 = Release|Any CPU + {D40EBE29-0DF7-494A-9B64-84CA9FC4D87B}.Release|x86.ActiveCfg = Release|Any CPU + {D40EBE29-0DF7-494A-9B64-84CA9FC4D87B}.Release|x86.Build.0 = Release|Any CPU + {17F76803-025A-41A1-B91A-9E2B70CCF70B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {17F76803-025A-41A1-B91A-9E2B70CCF70B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {17F76803-025A-41A1-B91A-9E2B70CCF70B}.Debug|x64.ActiveCfg = Debug|Any CPU + {17F76803-025A-41A1-B91A-9E2B70CCF70B}.Debug|x64.Build.0 = Debug|Any CPU + {17F76803-025A-41A1-B91A-9E2B70CCF70B}.Debug|x86.ActiveCfg = Debug|Any CPU + {17F76803-025A-41A1-B91A-9E2B70CCF70B}.Debug|x86.Build.0 = Debug|Any CPU + {17F76803-025A-41A1-B91A-9E2B70CCF70B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {17F76803-025A-41A1-B91A-9E2B70CCF70B}.Release|Any CPU.Build.0 = Release|Any CPU + {17F76803-025A-41A1-B91A-9E2B70CCF70B}.Release|x64.ActiveCfg = Release|Any CPU + {17F76803-025A-41A1-B91A-9E2B70CCF70B}.Release|x64.Build.0 = Release|Any CPU + {17F76803-025A-41A1-B91A-9E2B70CCF70B}.Release|x86.ActiveCfg = Release|Any CPU + {17F76803-025A-41A1-B91A-9E2B70CCF70B}.Release|x86.Build.0 = Release|Any CPU + {0F702691-51BD-4F55-9818-AA5E7616B507}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0F702691-51BD-4F55-9818-AA5E7616B507}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0F702691-51BD-4F55-9818-AA5E7616B507}.Debug|x64.ActiveCfg = Debug|Any CPU + {0F702691-51BD-4F55-9818-AA5E7616B507}.Debug|x64.Build.0 = Debug|Any CPU + {0F702691-51BD-4F55-9818-AA5E7616B507}.Debug|x86.ActiveCfg = Debug|Any CPU + {0F702691-51BD-4F55-9818-AA5E7616B507}.Debug|x86.Build.0 = Debug|Any CPU + {0F702691-51BD-4F55-9818-AA5E7616B507}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0F702691-51BD-4F55-9818-AA5E7616B507}.Release|Any CPU.Build.0 = Release|Any CPU + {0F702691-51BD-4F55-9818-AA5E7616B507}.Release|x64.ActiveCfg = Release|Any CPU + {0F702691-51BD-4F55-9818-AA5E7616B507}.Release|x64.Build.0 = Release|Any CPU + {0F702691-51BD-4F55-9818-AA5E7616B507}.Release|x86.ActiveCfg = Release|Any CPU + {0F702691-51BD-4F55-9818-AA5E7616B507}.Release|x86.Build.0 = Release|Any CPU + {A12941F8-AA71-477F-AC5F-37FD66253807}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A12941F8-AA71-477F-AC5F-37FD66253807}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A12941F8-AA71-477F-AC5F-37FD66253807}.Debug|x64.ActiveCfg = Debug|Any CPU + {A12941F8-AA71-477F-AC5F-37FD66253807}.Debug|x64.Build.0 = Debug|Any CPU + {A12941F8-AA71-477F-AC5F-37FD66253807}.Debug|x86.ActiveCfg = Debug|Any CPU + {A12941F8-AA71-477F-AC5F-37FD66253807}.Debug|x86.Build.0 = Debug|Any CPU + {A12941F8-AA71-477F-AC5F-37FD66253807}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A12941F8-AA71-477F-AC5F-37FD66253807}.Release|Any CPU.Build.0 = Release|Any CPU + {A12941F8-AA71-477F-AC5F-37FD66253807}.Release|x64.ActiveCfg = Release|Any CPU + {A12941F8-AA71-477F-AC5F-37FD66253807}.Release|x64.Build.0 = Release|Any CPU + {A12941F8-AA71-477F-AC5F-37FD66253807}.Release|x86.ActiveCfg = Release|Any CPU + {A12941F8-AA71-477F-AC5F-37FD66253807}.Release|x86.Build.0 = Release|Any CPU + {FEB14BF3-536E-467C-930E-3F253B90A145}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FEB14BF3-536E-467C-930E-3F253B90A145}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FEB14BF3-536E-467C-930E-3F253B90A145}.Debug|x64.ActiveCfg = Debug|Any CPU + {FEB14BF3-536E-467C-930E-3F253B90A145}.Debug|x64.Build.0 = Debug|Any CPU + {FEB14BF3-536E-467C-930E-3F253B90A145}.Debug|x86.ActiveCfg = Debug|Any CPU + {FEB14BF3-536E-467C-930E-3F253B90A145}.Debug|x86.Build.0 = Debug|Any CPU + {FEB14BF3-536E-467C-930E-3F253B90A145}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FEB14BF3-536E-467C-930E-3F253B90A145}.Release|Any CPU.Build.0 = Release|Any CPU + {FEB14BF3-536E-467C-930E-3F253B90A145}.Release|x64.ActiveCfg = Release|Any CPU + {FEB14BF3-536E-467C-930E-3F253B90A145}.Release|x64.Build.0 = Release|Any CPU + {FEB14BF3-536E-467C-930E-3F253B90A145}.Release|x86.ActiveCfg = Release|Any CPU + {FEB14BF3-536E-467C-930E-3F253B90A145}.Release|x86.Build.0 = Release|Any CPU + {9E6C8A4F-F911-4F9E-8E2F-2583C792A800}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9E6C8A4F-F911-4F9E-8E2F-2583C792A800}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9E6C8A4F-F911-4F9E-8E2F-2583C792A800}.Debug|x64.ActiveCfg = Debug|Any CPU + {9E6C8A4F-F911-4F9E-8E2F-2583C792A800}.Debug|x64.Build.0 = Debug|Any CPU + {9E6C8A4F-F911-4F9E-8E2F-2583C792A800}.Debug|x86.ActiveCfg = Debug|Any CPU + {9E6C8A4F-F911-4F9E-8E2F-2583C792A800}.Debug|x86.Build.0 = Debug|Any CPU + {9E6C8A4F-F911-4F9E-8E2F-2583C792A800}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9E6C8A4F-F911-4F9E-8E2F-2583C792A800}.Release|Any CPU.Build.0 = Release|Any CPU + {9E6C8A4F-F911-4F9E-8E2F-2583C792A800}.Release|x64.ActiveCfg = Release|Any CPU + {9E6C8A4F-F911-4F9E-8E2F-2583C792A800}.Release|x64.Build.0 = Release|Any CPU + {9E6C8A4F-F911-4F9E-8E2F-2583C792A800}.Release|x86.ActiveCfg = Release|Any CPU + {9E6C8A4F-F911-4F9E-8E2F-2583C792A800}.Release|x86.Build.0 = Release|Any CPU + {445C10D4-F39E-4048-BA13-28FBFB9DCD63}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {445C10D4-F39E-4048-BA13-28FBFB9DCD63}.Debug|Any CPU.Build.0 = Debug|Any CPU + {445C10D4-F39E-4048-BA13-28FBFB9DCD63}.Debug|x64.ActiveCfg = Debug|Any CPU + {445C10D4-F39E-4048-BA13-28FBFB9DCD63}.Debug|x64.Build.0 = Debug|Any CPU + {445C10D4-F39E-4048-BA13-28FBFB9DCD63}.Debug|x86.ActiveCfg = Debug|Any CPU + {445C10D4-F39E-4048-BA13-28FBFB9DCD63}.Debug|x86.Build.0 = Debug|Any CPU + {445C10D4-F39E-4048-BA13-28FBFB9DCD63}.Release|Any CPU.ActiveCfg = Release|Any CPU + {445C10D4-F39E-4048-BA13-28FBFB9DCD63}.Release|Any CPU.Build.0 = Release|Any CPU + {445C10D4-F39E-4048-BA13-28FBFB9DCD63}.Release|x64.ActiveCfg = Release|Any CPU + {445C10D4-F39E-4048-BA13-28FBFB9DCD63}.Release|x64.Build.0 = Release|Any CPU + {445C10D4-F39E-4048-BA13-28FBFB9DCD63}.Release|x86.ActiveCfg = Release|Any CPU + {445C10D4-F39E-4048-BA13-28FBFB9DCD63}.Release|x86.Build.0 = Release|Any CPU + {7E02B4A7-C232-4B63-B56D-38683D659FDE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7E02B4A7-C232-4B63-B56D-38683D659FDE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7E02B4A7-C232-4B63-B56D-38683D659FDE}.Debug|x64.ActiveCfg = Debug|Any CPU + {7E02B4A7-C232-4B63-B56D-38683D659FDE}.Debug|x64.Build.0 = Debug|Any CPU + {7E02B4A7-C232-4B63-B56D-38683D659FDE}.Debug|x86.ActiveCfg = Debug|Any CPU + {7E02B4A7-C232-4B63-B56D-38683D659FDE}.Debug|x86.Build.0 = Debug|Any CPU + {7E02B4A7-C232-4B63-B56D-38683D659FDE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7E02B4A7-C232-4B63-B56D-38683D659FDE}.Release|Any CPU.Build.0 = Release|Any CPU + {7E02B4A7-C232-4B63-B56D-38683D659FDE}.Release|x64.ActiveCfg = Release|Any CPU + {7E02B4A7-C232-4B63-B56D-38683D659FDE}.Release|x64.Build.0 = Release|Any CPU + {7E02B4A7-C232-4B63-B56D-38683D659FDE}.Release|x86.ActiveCfg = Release|Any CPU + {7E02B4A7-C232-4B63-B56D-38683D659FDE}.Release|x86.Build.0 = Release|Any CPU + {3B2C28CD-FB49-44D9-9FC4-76A71F815FC4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3B2C28CD-FB49-44D9-9FC4-76A71F815FC4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3B2C28CD-FB49-44D9-9FC4-76A71F815FC4}.Debug|x64.ActiveCfg = Debug|Any CPU + {3B2C28CD-FB49-44D9-9FC4-76A71F815FC4}.Debug|x64.Build.0 = Debug|Any CPU + {3B2C28CD-FB49-44D9-9FC4-76A71F815FC4}.Debug|x86.ActiveCfg = Debug|Any CPU + {3B2C28CD-FB49-44D9-9FC4-76A71F815FC4}.Debug|x86.Build.0 = Debug|Any CPU + {3B2C28CD-FB49-44D9-9FC4-76A71F815FC4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3B2C28CD-FB49-44D9-9FC4-76A71F815FC4}.Release|Any CPU.Build.0 = Release|Any CPU + {3B2C28CD-FB49-44D9-9FC4-76A71F815FC4}.Release|x64.ActiveCfg = Release|Any CPU + {3B2C28CD-FB49-44D9-9FC4-76A71F815FC4}.Release|x64.Build.0 = Release|Any CPU + {3B2C28CD-FB49-44D9-9FC4-76A71F815FC4}.Release|x86.ActiveCfg = Release|Any CPU + {3B2C28CD-FB49-44D9-9FC4-76A71F815FC4}.Release|x86.Build.0 = Release|Any CPU + {F787C38F-16C2-4EDE-9D28-E0EDE13215B3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F787C38F-16C2-4EDE-9D28-E0EDE13215B3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F787C38F-16C2-4EDE-9D28-E0EDE13215B3}.Debug|x64.ActiveCfg = Debug|Any CPU + {F787C38F-16C2-4EDE-9D28-E0EDE13215B3}.Debug|x64.Build.0 = Debug|Any CPU + {F787C38F-16C2-4EDE-9D28-E0EDE13215B3}.Debug|x86.ActiveCfg = Debug|Any CPU + {F787C38F-16C2-4EDE-9D28-E0EDE13215B3}.Debug|x86.Build.0 = Debug|Any CPU + {F787C38F-16C2-4EDE-9D28-E0EDE13215B3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F787C38F-16C2-4EDE-9D28-E0EDE13215B3}.Release|Any CPU.Build.0 = Release|Any CPU + {F787C38F-16C2-4EDE-9D28-E0EDE13215B3}.Release|x64.ActiveCfg = Release|Any CPU + {F787C38F-16C2-4EDE-9D28-E0EDE13215B3}.Release|x64.Build.0 = Release|Any CPU + {F787C38F-16C2-4EDE-9D28-E0EDE13215B3}.Release|x86.ActiveCfg = Release|Any CPU + {F787C38F-16C2-4EDE-9D28-E0EDE13215B3}.Release|x86.Build.0 = Release|Any CPU + {1EDCE8B0-14B1-4C65-9619-92A3D88FC587}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1EDCE8B0-14B1-4C65-9619-92A3D88FC587}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1EDCE8B0-14B1-4C65-9619-92A3D88FC587}.Debug|x64.ActiveCfg = Debug|Any CPU + {1EDCE8B0-14B1-4C65-9619-92A3D88FC587}.Debug|x64.Build.0 = Debug|Any CPU + {1EDCE8B0-14B1-4C65-9619-92A3D88FC587}.Debug|x86.ActiveCfg = Debug|Any CPU + {1EDCE8B0-14B1-4C65-9619-92A3D88FC587}.Debug|x86.Build.0 = Debug|Any CPU + {1EDCE8B0-14B1-4C65-9619-92A3D88FC587}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1EDCE8B0-14B1-4C65-9619-92A3D88FC587}.Release|Any CPU.Build.0 = Release|Any CPU + {1EDCE8B0-14B1-4C65-9619-92A3D88FC587}.Release|x64.ActiveCfg = Release|Any CPU + {1EDCE8B0-14B1-4C65-9619-92A3D88FC587}.Release|x64.Build.0 = Release|Any CPU + {1EDCE8B0-14B1-4C65-9619-92A3D88FC587}.Release|x86.ActiveCfg = Release|Any CPU + {1EDCE8B0-14B1-4C65-9619-92A3D88FC587}.Release|x86.Build.0 = Release|Any CPU + {E2EB8A87-973D-444F-81E4-2BA34B3CD32D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E2EB8A87-973D-444F-81E4-2BA34B3CD32D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E2EB8A87-973D-444F-81E4-2BA34B3CD32D}.Debug|x64.ActiveCfg = Debug|Any CPU + {E2EB8A87-973D-444F-81E4-2BA34B3CD32D}.Debug|x64.Build.0 = Debug|Any CPU + {E2EB8A87-973D-444F-81E4-2BA34B3CD32D}.Debug|x86.ActiveCfg = Debug|Any CPU + {E2EB8A87-973D-444F-81E4-2BA34B3CD32D}.Debug|x86.Build.0 = Debug|Any CPU + {E2EB8A87-973D-444F-81E4-2BA34B3CD32D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E2EB8A87-973D-444F-81E4-2BA34B3CD32D}.Release|Any CPU.Build.0 = Release|Any CPU + {E2EB8A87-973D-444F-81E4-2BA34B3CD32D}.Release|x64.ActiveCfg = Release|Any CPU + {E2EB8A87-973D-444F-81E4-2BA34B3CD32D}.Release|x64.Build.0 = Release|Any CPU + {E2EB8A87-973D-444F-81E4-2BA34B3CD32D}.Release|x86.ActiveCfg = Release|Any CPU + {E2EB8A87-973D-444F-81E4-2BA34B3CD32D}.Release|x86.Build.0 = Release|Any CPU + {0901A59F-AE0F-4B55-98B0-05FF23387FF7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0901A59F-AE0F-4B55-98B0-05FF23387FF7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0901A59F-AE0F-4B55-98B0-05FF23387FF7}.Debug|x64.ActiveCfg = Debug|Any CPU + {0901A59F-AE0F-4B55-98B0-05FF23387FF7}.Debug|x64.Build.0 = Debug|Any CPU + {0901A59F-AE0F-4B55-98B0-05FF23387FF7}.Debug|x86.ActiveCfg = Debug|Any CPU + {0901A59F-AE0F-4B55-98B0-05FF23387FF7}.Debug|x86.Build.0 = Debug|Any CPU + {0901A59F-AE0F-4B55-98B0-05FF23387FF7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0901A59F-AE0F-4B55-98B0-05FF23387FF7}.Release|Any CPU.Build.0 = Release|Any CPU + {0901A59F-AE0F-4B55-98B0-05FF23387FF7}.Release|x64.ActiveCfg = Release|Any CPU + {0901A59F-AE0F-4B55-98B0-05FF23387FF7}.Release|x64.Build.0 = Release|Any CPU + {0901A59F-AE0F-4B55-98B0-05FF23387FF7}.Release|x86.ActiveCfg = Release|Any CPU + {0901A59F-AE0F-4B55-98B0-05FF23387FF7}.Release|x86.Build.0 = Release|Any CPU + {0AE788C5-4205-4A07-A1AA-6A0A32AF72F3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0AE788C5-4205-4A07-A1AA-6A0A32AF72F3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0AE788C5-4205-4A07-A1AA-6A0A32AF72F3}.Debug|x64.ActiveCfg = Debug|Any CPU + {0AE788C5-4205-4A07-A1AA-6A0A32AF72F3}.Debug|x64.Build.0 = Debug|Any CPU + {0AE788C5-4205-4A07-A1AA-6A0A32AF72F3}.Debug|x86.ActiveCfg = Debug|Any CPU + {0AE788C5-4205-4A07-A1AA-6A0A32AF72F3}.Debug|x86.Build.0 = Debug|Any CPU + {0AE788C5-4205-4A07-A1AA-6A0A32AF72F3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0AE788C5-4205-4A07-A1AA-6A0A32AF72F3}.Release|Any CPU.Build.0 = Release|Any CPU + {0AE788C5-4205-4A07-A1AA-6A0A32AF72F3}.Release|x64.ActiveCfg = Release|Any CPU + {0AE788C5-4205-4A07-A1AA-6A0A32AF72F3}.Release|x64.Build.0 = Release|Any CPU + {0AE788C5-4205-4A07-A1AA-6A0A32AF72F3}.Release|x86.ActiveCfg = Release|Any CPU + {0AE788C5-4205-4A07-A1AA-6A0A32AF72F3}.Release|x86.Build.0 = Release|Any CPU + {F808372B-E577-4632-A733-A6BBEFAE3FE7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F808372B-E577-4632-A733-A6BBEFAE3FE7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F808372B-E577-4632-A733-A6BBEFAE3FE7}.Debug|x64.ActiveCfg = Debug|Any CPU + {F808372B-E577-4632-A733-A6BBEFAE3FE7}.Debug|x64.Build.0 = Debug|Any CPU + {F808372B-E577-4632-A733-A6BBEFAE3FE7}.Debug|x86.ActiveCfg = Debug|Any CPU + {F808372B-E577-4632-A733-A6BBEFAE3FE7}.Debug|x86.Build.0 = Debug|Any CPU + {F808372B-E577-4632-A733-A6BBEFAE3FE7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F808372B-E577-4632-A733-A6BBEFAE3FE7}.Release|Any CPU.Build.0 = Release|Any CPU + {F808372B-E577-4632-A733-A6BBEFAE3FE7}.Release|x64.ActiveCfg = Release|Any CPU + {F808372B-E577-4632-A733-A6BBEFAE3FE7}.Release|x64.Build.0 = Release|Any CPU + {F808372B-E577-4632-A733-A6BBEFAE3FE7}.Release|x86.ActiveCfg = Release|Any CPU + {F808372B-E577-4632-A733-A6BBEFAE3FE7}.Release|x86.Build.0 = Release|Any CPU + {1A6DE1F4-AB77-4AA6-8EE7-080F918F1F60}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1A6DE1F4-AB77-4AA6-8EE7-080F918F1F60}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1A6DE1F4-AB77-4AA6-8EE7-080F918F1F60}.Debug|x64.ActiveCfg = Debug|Any CPU + {1A6DE1F4-AB77-4AA6-8EE7-080F918F1F60}.Debug|x64.Build.0 = Debug|Any CPU + {1A6DE1F4-AB77-4AA6-8EE7-080F918F1F60}.Debug|x86.ActiveCfg = Debug|Any CPU + {1A6DE1F4-AB77-4AA6-8EE7-080F918F1F60}.Debug|x86.Build.0 = Debug|Any CPU + {1A6DE1F4-AB77-4AA6-8EE7-080F918F1F60}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1A6DE1F4-AB77-4AA6-8EE7-080F918F1F60}.Release|Any CPU.Build.0 = Release|Any CPU + {1A6DE1F4-AB77-4AA6-8EE7-080F918F1F60}.Release|x64.ActiveCfg = Release|Any CPU + {1A6DE1F4-AB77-4AA6-8EE7-080F918F1F60}.Release|x64.Build.0 = Release|Any CPU + {1A6DE1F4-AB77-4AA6-8EE7-080F918F1F60}.Release|x86.ActiveCfg = Release|Any CPU + {1A6DE1F4-AB77-4AA6-8EE7-080F918F1F60}.Release|x86.Build.0 = Release|Any CPU + {1124B5DC-D042-4396-88E2-C1DB42ED198C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1124B5DC-D042-4396-88E2-C1DB42ED198C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1124B5DC-D042-4396-88E2-C1DB42ED198C}.Debug|x64.ActiveCfg = Debug|Any CPU + {1124B5DC-D042-4396-88E2-C1DB42ED198C}.Debug|x64.Build.0 = Debug|Any CPU + {1124B5DC-D042-4396-88E2-C1DB42ED198C}.Debug|x86.ActiveCfg = Debug|Any CPU + {1124B5DC-D042-4396-88E2-C1DB42ED198C}.Debug|x86.Build.0 = Debug|Any CPU + {1124B5DC-D042-4396-88E2-C1DB42ED198C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1124B5DC-D042-4396-88E2-C1DB42ED198C}.Release|Any CPU.Build.0 = Release|Any CPU + {1124B5DC-D042-4396-88E2-C1DB42ED198C}.Release|x64.ActiveCfg = Release|Any CPU + {1124B5DC-D042-4396-88E2-C1DB42ED198C}.Release|x64.Build.0 = Release|Any CPU + {1124B5DC-D042-4396-88E2-C1DB42ED198C}.Release|x86.ActiveCfg = Release|Any CPU + {1124B5DC-D042-4396-88E2-C1DB42ED198C}.Release|x86.Build.0 = Release|Any CPU + {069467F4-AD60-4098-82CE-C09304303F4E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {069467F4-AD60-4098-82CE-C09304303F4E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {069467F4-AD60-4098-82CE-C09304303F4E}.Debug|x64.ActiveCfg = Debug|Any CPU + {069467F4-AD60-4098-82CE-C09304303F4E}.Debug|x64.Build.0 = Debug|Any CPU + {069467F4-AD60-4098-82CE-C09304303F4E}.Debug|x86.ActiveCfg = Debug|Any CPU + {069467F4-AD60-4098-82CE-C09304303F4E}.Debug|x86.Build.0 = Debug|Any CPU + {069467F4-AD60-4098-82CE-C09304303F4E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {069467F4-AD60-4098-82CE-C09304303F4E}.Release|Any CPU.Build.0 = Release|Any CPU + {069467F4-AD60-4098-82CE-C09304303F4E}.Release|x64.ActiveCfg = Release|Any CPU + {069467F4-AD60-4098-82CE-C09304303F4E}.Release|x64.Build.0 = Release|Any CPU + {069467F4-AD60-4098-82CE-C09304303F4E}.Release|x86.ActiveCfg = Release|Any CPU + {069467F4-AD60-4098-82CE-C09304303F4E}.Release|x86.Build.0 = Release|Any CPU + {90BD1FE2-AD58-41F3-A351-612E167708FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {90BD1FE2-AD58-41F3-A351-612E167708FF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {90BD1FE2-AD58-41F3-A351-612E167708FF}.Debug|x64.ActiveCfg = Debug|Any CPU + {90BD1FE2-AD58-41F3-A351-612E167708FF}.Debug|x64.Build.0 = Debug|Any CPU + {90BD1FE2-AD58-41F3-A351-612E167708FF}.Debug|x86.ActiveCfg = Debug|Any CPU + {90BD1FE2-AD58-41F3-A351-612E167708FF}.Debug|x86.Build.0 = Debug|Any CPU + {90BD1FE2-AD58-41F3-A351-612E167708FF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {90BD1FE2-AD58-41F3-A351-612E167708FF}.Release|Any CPU.Build.0 = Release|Any CPU + {90BD1FE2-AD58-41F3-A351-612E167708FF}.Release|x64.ActiveCfg = Release|Any CPU + {90BD1FE2-AD58-41F3-A351-612E167708FF}.Release|x64.Build.0 = Release|Any CPU + {90BD1FE2-AD58-41F3-A351-612E167708FF}.Release|x86.ActiveCfg = Release|Any CPU + {90BD1FE2-AD58-41F3-A351-612E167708FF}.Release|x86.Build.0 = Release|Any CPU + {EE529C09-917B-4FC0-AF19-0092B954F300}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EE529C09-917B-4FC0-AF19-0092B954F300}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EE529C09-917B-4FC0-AF19-0092B954F300}.Debug|x64.ActiveCfg = Debug|Any CPU + {EE529C09-917B-4FC0-AF19-0092B954F300}.Debug|x64.Build.0 = Debug|Any CPU + {EE529C09-917B-4FC0-AF19-0092B954F300}.Debug|x86.ActiveCfg = Debug|Any CPU + {EE529C09-917B-4FC0-AF19-0092B954F300}.Debug|x86.Build.0 = Debug|Any CPU + {EE529C09-917B-4FC0-AF19-0092B954F300}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EE529C09-917B-4FC0-AF19-0092B954F300}.Release|Any CPU.Build.0 = Release|Any CPU + {EE529C09-917B-4FC0-AF19-0092B954F300}.Release|x64.ActiveCfg = Release|Any CPU + {EE529C09-917B-4FC0-AF19-0092B954F300}.Release|x64.Build.0 = Release|Any CPU + {EE529C09-917B-4FC0-AF19-0092B954F300}.Release|x86.ActiveCfg = Release|Any CPU + {EE529C09-917B-4FC0-AF19-0092B954F300}.Release|x86.Build.0 = Release|Any CPU + {1B039528-384B-414A-B2C0-61738DC8CAA8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1B039528-384B-414A-B2C0-61738DC8CAA8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1B039528-384B-414A-B2C0-61738DC8CAA8}.Debug|x64.ActiveCfg = Debug|Any CPU + {1B039528-384B-414A-B2C0-61738DC8CAA8}.Debug|x64.Build.0 = Debug|Any CPU + {1B039528-384B-414A-B2C0-61738DC8CAA8}.Debug|x86.ActiveCfg = Debug|Any CPU + {1B039528-384B-414A-B2C0-61738DC8CAA8}.Debug|x86.Build.0 = Debug|Any CPU + {1B039528-384B-414A-B2C0-61738DC8CAA8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1B039528-384B-414A-B2C0-61738DC8CAA8}.Release|Any CPU.Build.0 = Release|Any CPU + {1B039528-384B-414A-B2C0-61738DC8CAA8}.Release|x64.ActiveCfg = Release|Any CPU + {1B039528-384B-414A-B2C0-61738DC8CAA8}.Release|x64.Build.0 = Release|Any CPU + {1B039528-384B-414A-B2C0-61738DC8CAA8}.Release|x86.ActiveCfg = Release|Any CPU + {1B039528-384B-414A-B2C0-61738DC8CAA8}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {818132FC-82E4-4E0C-8D9D-DB9FAA82EC17} = {ABB2D577-535D-D48F-8A83-D0D3A05DDA5C} + {762A4F83-200A-4784-939C-30DDCCD07542} = {ABB2D577-535D-D48F-8A83-D0D3A05DDA5C} + {F7C739C1-1C1A-45FC-A5CF-19C85F88C492} = {ABB2D577-535D-D48F-8A83-D0D3A05DDA5C} + {2A46F144-074D-406E-8891-218DF31A92FA} = {ABB2D577-535D-D48F-8A83-D0D3A05DDA5C} + {5C5CC11C-846A-469B-A1C7-1CE3A9E53918} = {ABB2D577-535D-D48F-8A83-D0D3A05DDA5C} + {49DD4823-A00F-48CD-A374-088B6C8E8D7E} = {EC6922CA-170E-0A2A-CC7E-F73B34E70907} + {431FAA2C-9E7B-452B-B39F-82B08746E065} = {EC6922CA-170E-0A2A-CC7E-F73B34E70907} + {3C452E09-F2E5-4B2A-BFB0-CCF0D61D5C33} = {EC6922CA-170E-0A2A-CC7E-F73B34E70907} + {F0FF9E38-F849-4807-81B7-8723A0D9935E} = {EC6922CA-170E-0A2A-CC7E-F73B34E70907} + {10FA361F-FD9B-4BF2-BF17-3A1FCC203B9B} = {EC6922CA-170E-0A2A-CC7E-F73B34E70907} + {5197E122-7973-4DFC-816D-5AB8F9F20D62} = {EC6922CA-170E-0A2A-CC7E-F73B34E70907} + {066DBED8-9500-4EDD-AE69-9735F1A34DB5} = {37512E4E-BED2-30A1-7091-F8823DB56BB0} + {0023B98F-FAA0-40DE-8047-37A57B72287B} = {37512E4E-BED2-30A1-7091-F8823DB56BB0} + {9F334D4D-D520-4E36-A3A0-FCF17D5027FE} = {37512E4E-BED2-30A1-7091-F8823DB56BB0} + {618D8E73-942A-4C6A-B6DF-C3E24377D1A6} = {37512E4E-BED2-30A1-7091-F8823DB56BB0} + {0695060C-08C2-4AD7-9906-3D7F17AF85F8} = {37512E4E-BED2-30A1-7091-F8823DB56BB0} + {3188FDDE-FBF2-43FD-A841-40766012C8A2} = {37512E4E-BED2-30A1-7091-F8823DB56BB0} + {8534D3A8-7CBE-42A0-BAAB-68F9D0BFFA0C} = {E2866E30-6F85-1E9C-4A7D-9D94AC184FFF} + {BFFF4AD6-8508-465F-B087-4CEE919D5909} = {E2866E30-6F85-1E9C-4A7D-9D94AC184FFF} + {927D22CA-7788-4035-A400-8B3F2CAEA650} = {E2866E30-6F85-1E9C-4A7D-9D94AC184FFF} + {D40EBE29-0DF7-494A-9B64-84CA9FC4D87B} = {E2866E30-6F85-1E9C-4A7D-9D94AC184FFF} + {17F76803-025A-41A1-B91A-9E2B70CCF70B} = {E2866E30-6F85-1E9C-4A7D-9D94AC184FFF} + {0F702691-51BD-4F55-9818-AA5E7616B507} = {E2866E30-6F85-1E9C-4A7D-9D94AC184FFF} + {A12941F8-AA71-477F-AC5F-37FD66253807} = {05210415-73DC-ACC3-ABDB-6AAB38F7838E} + {FEB14BF3-536E-467C-930E-3F253B90A145} = {05210415-73DC-ACC3-ABDB-6AAB38F7838E} + {9E6C8A4F-F911-4F9E-8E2F-2583C792A800} = {05210415-73DC-ACC3-ABDB-6AAB38F7838E} + {445C10D4-F39E-4048-BA13-28FBFB9DCD63} = {05210415-73DC-ACC3-ABDB-6AAB38F7838E} + {7E02B4A7-C232-4B63-B56D-38683D659FDE} = {05210415-73DC-ACC3-ABDB-6AAB38F7838E} + {3B2C28CD-FB49-44D9-9FC4-76A71F815FC4} = {05210415-73DC-ACC3-ABDB-6AAB38F7838E} + {F787C38F-16C2-4EDE-9D28-E0EDE13215B3} = {06DC3261-A065-1F52-480A-5354B826AC99} + {1EDCE8B0-14B1-4C65-9619-92A3D88FC587} = {06DC3261-A065-1F52-480A-5354B826AC99} + {E2EB8A87-973D-444F-81E4-2BA34B3CD32D} = {06DC3261-A065-1F52-480A-5354B826AC99} + {0901A59F-AE0F-4B55-98B0-05FF23387FF7} = {06DC3261-A065-1F52-480A-5354B826AC99} + {0AE788C5-4205-4A07-A1AA-6A0A32AF72F3} = {06DC3261-A065-1F52-480A-5354B826AC99} + {F808372B-E577-4632-A733-A6BBEFAE3FE7} = {06DC3261-A065-1F52-480A-5354B826AC99} + {1A6DE1F4-AB77-4AA6-8EE7-080F918F1F60} = {442FB6B7-7A89-545F-4529-C1729FE94F9C} + {1124B5DC-D042-4396-88E2-C1DB42ED198C} = {442FB6B7-7A89-545F-4529-C1729FE94F9C} + {069467F4-AD60-4098-82CE-C09304303F4E} = {442FB6B7-7A89-545F-4529-C1729FE94F9C} + {90BD1FE2-AD58-41F3-A351-612E167708FF} = {442FB6B7-7A89-545F-4529-C1729FE94F9C} + {EE529C09-917B-4FC0-AF19-0092B954F300} = {442FB6B7-7A89-545F-4529-C1729FE94F9C} + {1B039528-384B-414A-B2C0-61738DC8CAA8} = {442FB6B7-7A89-545F-4529-C1729FE94F9C} + EndGlobalSection +EndGlobal diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer.Tests/AccountOwnerServer.Tests.csproj b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer.Tests/AccountOwnerServer.Tests.csproj new file mode 100644 index 000000000..338bac1ce --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer.Tests/AccountOwnerServer.Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + false + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer.Tests/DataShaperTests.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer.Tests/DataShaperTests.cs new file mode 100644 index 000000000..0ac05946f --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer.Tests/DataShaperTests.cs @@ -0,0 +1,56 @@ +using Entities.Helpers; +using Entities.Models; +using System.Linq; +using Xunit; + +namespace AccountOwnerServer.Tests; + +public class DataShaperTests +{ + private static readonly DataShaper Shaper = new(); + + [Fact] + public void GivenNoFields_WhenWeShape_ThenEveryPropertyIsKept() + { + var shaped = Shaper.ShapeData(SqlServerSample.Owners().First(), null); + + Assert.Equal(4, shaped.Count); + Assert.True(shaped.ContainsKey(nameof(Owner.Address))); + } + + [Fact] + public void GivenASubsetOfFields_WhenWeShape_ThenOnlyThoseComeBack() + { + var shaped = Shaper.ShapeData(SqlServerSample.Owners().First(), "name,dateOfBirth"); + + Assert.Equal(2, shaped.Count); + Assert.True(shaped.ContainsKey(nameof(Owner.Name))); + Assert.False(shaped.ContainsKey(nameof(Owner.Address))); + } + + [Fact] + public void GivenAFieldInAnyCasing_WhenWeShape_ThenItStillMatchesThePropertyName() + { + var shaped = Shaper.ShapeData(SqlServerSample.Owners().First(), "NAME"); + + Assert.True(shaped.ContainsKey(nameof(Owner.Name))); + } + + [Fact] + public void GivenAnUnknownField_WhenWeShape_ThenItIsIgnored() + { + var shaped = Shaper.ShapeData(SqlServerSample.Owners().First(), "name,age"); + + Assert.Single(shaped); + Assert.True(shaped.ContainsKey(nameof(Owner.Name))); + } + + [Fact] + public void GivenACollection_WhenWeShape_ThenEveryEntityIsShaped() + { + var shaped = Shaper.ShapeData(SqlServerSample.Owners(), "name").ToList(); + + Assert.Equal(5, shaped.Count); + Assert.All(shaped, s => Assert.Single(s)); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer.Tests/OwnerParametersTests.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer.Tests/OwnerParametersTests.cs new file mode 100644 index 000000000..4ccdf0adb --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer.Tests/OwnerParametersTests.cs @@ -0,0 +1,41 @@ +using Entities.Models; +using Xunit; + +namespace AccountOwnerServer.Tests; + +public class OwnerParametersTests +{ + [Fact] + public void GivenTheSameMinAndMaxYear_WhenTheRangeIsValidated_ThenItIsAccepted() + { + var parameters = new OwnerParameters { MinYearOfBirth = 1990, MaxYearOfBirth = 1990 }; + + Assert.True(parameters.ValidYearRange); + } + + [Fact] + public void GivenAMaxYearBelowTheMinYear_WhenTheRangeIsValidated_ThenItIsRejected() + { + var parameters = new OwnerParameters { MinYearOfBirth = 1975, MaxYearOfBirth = 1974 }; + + Assert.False(parameters.ValidYearRange); + } + + [Fact] + public void GivenNoYearsAtAll_WhenTheRangeIsValidated_ThenItIsAccepted() + { + var parameters = new OwnerParameters(); + + Assert.Null(parameters.MinYearOfBirth); + Assert.Null(parameters.MaxYearOfBirth); + Assert.True(parameters.ValidYearRange); + } + + [Fact] + public void GivenOnlyAMinYear_WhenTheRangeIsValidated_ThenItIsAccepted() + { + var parameters = new OwnerParameters { MinYearOfBirth = 1975 }; + + Assert.True(parameters.ValidYearRange); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer.Tests/OwnerRepositorySqlServerTests.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer.Tests/OwnerRepositorySqlServerTests.cs new file mode 100644 index 000000000..ab07cf2ae --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer.Tests/OwnerRepositorySqlServerTests.cs @@ -0,0 +1,128 @@ +using Entities.Helpers; +using Entities.Models; +using Repository; +using System; +using System.Linq; +using System.Threading.Tasks; +using Xunit; + +namespace AccountOwnerServer.Tests; + +/// +/// These run against a real SQL Server started by Testcontainers. They are skipped unless +/// CODEMAZE_SQLSERVER_TESTS=1 is set, because the SQL Server image is not guaranteed to be +/// available on every machine or CI runner. +/// +public class OwnerRepositorySqlServerTests +{ + [SqlServerFact] + public async Task GivenAPageSizeOfTwo_WhenWeAskForTheSecondPage_ThenWeGetTwoOwnersAndTheRightMetadata() + { + await using var sample = await SqlServerSample.StartAsync(); + await using var context = sample.CreateContext(); + + var repository = new OwnerRepository(context, new SortHelper(), new DataShaper()); + + var owners = await repository.GetOwners(new OwnerParameters { PageNumber = 2, PageSize = 2 }); + + Assert.Equal(2, owners.Count); + Assert.Equal(5, owners.TotalCount); + Assert.Equal(3, owners.TotalPages); + Assert.True(owners.HasPrevious); + Assert.True(owners.HasNext); + } + + [SqlServerFact] + public async Task GivenTheSameMinAndMaxYear_WhenWeFilter_ThenTheOwnerBornInThatYearComesBack() + { + await using var sample = await SqlServerSample.StartAsync(); + await using var context = sample.CreateContext(); + + var repository = new OwnerRepository(context, new SortHelper(), new DataShaper()); + + var owners = await repository.GetOwners(new OwnerParameters + { + MinYearOfBirth = 1990, + MaxYearOfBirth = 1990 + }); + + Assert.Equal(1, owners.TotalCount); + Assert.Equal("Sam Query", NameOf(owners.Single())); + } + + [SqlServerFact] + public async Task GivenOnlyAMinimumYear_WhenWeFilter_ThenTheUpperBoundIsOpen() + { + await using var sample = await SqlServerSample.StartAsync(); + await using var context = sample.CreateContext(); + + var repository = new OwnerRepository(context, new SortHelper(), new DataShaper()); + + var owners = await repository.GetOwners(new OwnerParameters { MinYearOfBirth = 1990 }); + + Assert.Equal(2, owners.TotalCount); + } + + [SqlServerFact] + public async Task GivenALowerCaseSearchTerm_WhenWeSearch_ThenTheDefaultCollationStillMatches() + { + await using var sample = await SqlServerSample.StartAsync(); + await using var context = sample.CreateContext(); + + var repository = new OwnerRepository(context, new SortHelper(), new DataShaper()); + + var owners = await repository.GetOwners(new OwnerParameters { SearchTerm = "anna" }); + + Assert.Equal(1, owners.TotalCount); + Assert.Equal("Anna Bosh", NameOf(owners.Single())); + } + + [SqlServerFact] + public async Task GivenAnUnknownOrderByField_WhenWeQuery_ThenThePageIsStillOrderedRatherThanFailing() + { + await using var sample = await SqlServerSample.StartAsync(); + await using var context = sample.CreateContext(); + + var repository = new OwnerRepository(context, new SortHelper(), new DataShaper()); + + var owners = await repository.GetOwners(new OwnerParameters { OrderBy = "age" }); + + Assert.Equal(5, owners.TotalCount); + Assert.Equal("Anna Bosh", NameOf(owners.First())); + } + + [SqlServerFact] + public async Task GivenAnUpperCaseDescToken_WhenWeQuery_ThenTheOrderIsDescending() + { + await using var sample = await SqlServerSample.StartAsync(); + await using var context = sample.CreateContext(); + + var repository = new OwnerRepository(context, new SortHelper(), new DataShaper()); + + var owners = await repository.GetOwners(new OwnerParameters { OrderBy = "name DESC" }); + + Assert.Equal("Sam Query", NameOf(owners.First())); + } + + [SqlServerFact] + public async Task GivenFieldsAndAPageSize_WhenWeQuery_ThenOnlyThePageIsShapedAndTheMetadataComesFromTheQuery() + { + await using var sample = await SqlServerSample.StartAsync(); + await using var context = sample.CreateContext(); + + var repository = new OwnerRepository(context, new SortHelper(), new DataShaper()); + + var owners = await repository.GetOwners(new OwnerParameters + { + Fields = "name", + PageSize = 2, + PageNumber = 1 + }); + + Assert.Equal(2, owners.Count); + Assert.Equal(5, owners.TotalCount); + Assert.Equal(3, owners.TotalPages); + } + + private static string NameOf(Entity owner) => owner[nameof(Owner.Name)].ToString()!; +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer.Tests/QueryStringParametersTests.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer.Tests/QueryStringParametersTests.cs new file mode 100644 index 000000000..0b3791e6f --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer.Tests/QueryStringParametersTests.cs @@ -0,0 +1,39 @@ +using Entities.Models; +using Xunit; + +namespace AccountOwnerServer.Tests; + +public class QueryStringParametersTests +{ + [Theory] + [InlineData(0, 1)] + [InlineData(-3, 1)] + [InlineData(2, 2)] + public void GivenAPageNumber_WhenItIsSet_ThenItNeverDropsBelowOne(int requested, int expected) + { + var parameters = new OwnerParameters { PageNumber = requested }; + + Assert.Equal(expected, parameters.PageNumber); + } + + [Theory] + [InlineData(0, 1)] + [InlineData(-10, 1)] + [InlineData(7, 7)] + [InlineData(500, 50)] + public void GivenAPageSize_WhenItIsSet_ThenItIsClampedBetweenOneAndFifty(int requested, int expected) + { + var parameters = new OwnerParameters { PageSize = requested }; + + Assert.Equal(expected, parameters.PageSize); + } + + [Fact] + public void GivenNoQueryString_WhenParametersAreCreated_ThenTheDefaultsAreOneAndTen() + { + var parameters = new OwnerParameters(); + + Assert.Equal(1, parameters.PageNumber); + Assert.Equal(10, parameters.PageSize); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer.Tests/SearchExtensionTests.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer.Tests/SearchExtensionTests.cs new file mode 100644 index 000000000..e83fe53cc --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer.Tests/SearchExtensionTests.cs @@ -0,0 +1,48 @@ +using Entities.Models; +using Repository.Extensions; +using System.Linq; +using Xunit; + +namespace AccountOwnerServer.Tests; + +public class SearchExtensionTests +{ + private static IQueryable Owners() => SqlServerSample.Owners().AsQueryable(); + + [Fact] + public void GivenABlankSearchTerm_WhenWeSearch_ThenTheQueryIsReturnedUntouched() + { + var owners = Owners(); + + Assert.Same(owners, owners.Search(null)); + Assert.Same(owners, owners.Search(string.Empty)); + Assert.Same(owners, owners.Search(" ")); + } + + [Fact] + public void GivenANameFragment_WhenWeSearch_ThenTheMatchingOwnerComesBack() + { + var result = Owners().Search("Anna").ToList(); + + Assert.Single(result); + Assert.Equal("Anna Bosh", result[0].Name); + } + + [Fact] + public void GivenAnAddressFragment_WhenWeSearch_ThenTheColumnIsSearchedToo() + { + var result = Owners().Search("Wellfield").ToList(); + + Assert.Single(result); + Assert.Equal("John Keen", result[0].Name); + } + + [Fact] + public void GivenAPaddedSearchTerm_WhenWeSearch_ThenItIsTrimmedBeforeMatching() + { + var result = Owners().Search(" Somion ").ToList(); + + Assert.Single(result); + Assert.Equal("Nick Somion", result[0].Name); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer.Tests/SortHelperTests.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer.Tests/SortHelperTests.cs new file mode 100644 index 000000000..685ec28ad --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer.Tests/SortHelperTests.cs @@ -0,0 +1,61 @@ +using Entities.Helpers; +using Entities.Models; +using System.Linq; +using Xunit; + +namespace AccountOwnerServer.Tests; + +public class SortHelperTests +{ + private static IQueryable Owners() => SqlServerSample.Owners().AsQueryable(); + + private static readonly SortHelper Sorter = new(); + + [Fact] + public void GivenABlankOrderBy_WhenWeSort_ThenTheQueryIsReturnedUntouched() + { + var owners = Owners(); + + Assert.Same(owners, Sorter.ApplySort(owners, null)); + Assert.Same(owners, Sorter.ApplySort(owners, " ")); + } + + [Fact] + public void GivenOnlyUnknownFields_WhenWeSort_ThenTheQueryIsReturnedUntouchedRatherThanThrowing() + { + var owners = Owners(); + + var sorted = Sorter.ApplySort(owners, "age"); + + Assert.Same(owners, sorted); + } + + [Fact] + public void GivenAKnownField_WhenWeSort_ThenTheRowsComeBackInThatOrder() + { + var sorted = Sorter.ApplySort(Owners(), "dateOfBirth").ToList(); + + Assert.Equal("Anna Bosh", sorted.First().Name); + Assert.Equal("Nick Somion", sorted.Last().Name); + } + + [Theory] + [InlineData("name desc")] + [InlineData("name DESC")] + [InlineData("name Desc")] + public void GivenDescInAnyCasing_WhenWeSort_ThenTheOrderIsDescending(string orderBy) + { + var sorted = Sorter.ApplySort(Owners(), orderBy).ToList(); + + Assert.Equal("Sam Query", sorted.First().Name); + Assert.Equal("Anna Bosh", sorted.Last().Name); + } + + [Fact] + public void GivenAKnownAndAnUnknownField_WhenWeSort_ThenTheKnownOneStillApplies() + { + var sorted = Sorter.ApplySort(Owners(), "age,name desc").ToList(); + + Assert.Equal("Sam Query", sorted.First().Name); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer.Tests/SqlServerFactAttribute.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer.Tests/SqlServerFactAttribute.cs new file mode 100644 index 000000000..6ae7fafae --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer.Tests/SqlServerFactAttribute.cs @@ -0,0 +1,20 @@ +using System; +using Xunit; + +namespace AccountOwnerServer.Tests; + +/// +/// A fact that runs only when SQL Server integration tests are switched on with +/// CODEMAZE_SQLSERVER_TESTS=1. The tests need a working Docker daemon: Testcontainers +/// starts mcr.microsoft.com/mssql/server for them. +/// +public sealed class SqlServerFactAttribute : FactAttribute +{ + public SqlServerFactAttribute() + { + if (Environment.GetEnvironmentVariable("CODEMAZE_SQLSERVER_TESTS") != "1") + { + Skip = "Set CODEMAZE_SQLSERVER_TESTS=1 (and have Docker running) to run the SQL Server tests."; + } + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer.Tests/SqlServerSample.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer.Tests/SqlServerSample.cs new file mode 100644 index 000000000..e557d6730 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer.Tests/SqlServerSample.cs @@ -0,0 +1,73 @@ +using Entities; +using Entities.Models; +using Microsoft.EntityFrameworkCore; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Testcontainers.MsSql; + +namespace AccountOwnerServer.Tests; + +/// +/// A throwaway SQL Server container seeded with the five owners and eight accounts the +/// articles use. Each test owns its own container, so nothing is created when the tests +/// are skipped. +/// +public sealed class SqlServerSample : IAsyncDisposable +{ + private readonly MsSqlContainer _container; + + private SqlServerSample(MsSqlContainer container) + { + _container = container; + } + + public static async Task StartAsync() + { + var container = new MsSqlBuilder("mcr.microsoft.com/mssql/server:2022-latest").Build(); + await container.StartAsync(); + + var sample = new SqlServerSample(container); + + await using var context = sample.CreateContext(); + await context.Database.EnsureCreatedAsync(); + + context.Owners.AddRange(Owners()); + context.Accounts.AddRange(Accounts()); + await context.SaveChangesAsync(); + + return sample; + } + + public RepositoryContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseSqlServer(_container.GetConnectionString()) + .Options; + + return new RepositoryContext(options); + } + + public async ValueTask DisposeAsync() => await _container.DisposeAsync(); + + public static List Owners() => + [ + new() { Id = Guid.Parse("24fd81f8-d58a-4bcc-9f35-dc6cd5641906"), Name = "John Keen", DateOfBirth = new DateTime(1980, 12, 5), Address = "61 Wellfield Road" }, + new() { Id = Guid.Parse("261e1685-cf26-494c-b17c-3546e65f5620"), Name = "Anna Bosh", DateOfBirth = new DateTime(1974, 11, 14), Address = "27 Colored Row" }, + new() { Id = Guid.Parse("66774006-2371-4d5b-8518-2177bcf3f73e"), Name = "Nick Somion", DateOfBirth = new DateTime(1998, 12, 15), Address = "North sunny address 102" }, + new() { Id = Guid.Parse("a3c1880c-674c-4d18-8f91-5d3608a2c937"), Name = "Sam Query", DateOfBirth = new DateTime(1990, 4, 22), Address = "91 Western Roads" }, + new() { Id = Guid.Parse("f98e4d74-0f68-4aac-89fd-047f1aaca6b6"), Name = "Martin Miller", DateOfBirth = new DateTime(1983, 5, 21), Address = "3 Edgar Buildings" } + ]; + + public static List Accounts() => + [ + new() { Id = Guid.Parse("03e91478-5608-4132-a753-d494dafce00b"), DateCreated = new DateTime(2003, 12, 15), AccountType = "Domestic", OwnerId = Guid.Parse("f98e4d74-0f68-4aac-89fd-047f1aaca6b6") }, + new() { Id = Guid.Parse("356a5a9b-64bf-4de0-bc84-5395a1fdc9c4"), DateCreated = new DateTime(1996, 2, 15), AccountType = "Domestic", OwnerId = Guid.Parse("261e1685-cf26-494c-b17c-3546e65f5620") }, + new() { Id = Guid.Parse("371b93f2-f8c5-4a32-894a-fc672741aa5b"), DateCreated = new DateTime(1999, 5, 4), AccountType = "Domestic", OwnerId = Guid.Parse("24fd81f8-d58a-4bcc-9f35-dc6cd5641906") }, + new() { Id = Guid.Parse("670775db-ecc0-4b90-a9ab-37cd0d8e2801"), DateCreated = new DateTime(1999, 12, 21), AccountType = "Savings", OwnerId = Guid.Parse("24fd81f8-d58a-4bcc-9f35-dc6cd5641906") }, + new() { Id = Guid.Parse("a3fbad0b-7f48-4feb-8ac0-6d3bbc997bfc"), DateCreated = new DateTime(2010, 5, 28), AccountType = "Domestic", OwnerId = Guid.Parse("a3c1880c-674c-4d18-8f91-5d3608a2c937") }, + new() { Id = Guid.Parse("aa15f658-04bb-4f73-82af-82db49d0fbef"), DateCreated = new DateTime(1999, 5, 12), AccountType = "Foreign", OwnerId = Guid.Parse("24fd81f8-d58a-4bcc-9f35-dc6cd5641906") }, + new() { Id = Guid.Parse("c6066eb0-53ca-43e1-97aa-3c2169eec659"), DateCreated = new DateTime(1996, 2, 16), AccountType = "Foreign", OwnerId = Guid.Parse("261e1685-cf26-494c-b17c-3546e65f5620") }, + new() { Id = Guid.Parse("eccadf79-85fe-402f-893c-32d3f03ed9b1"), DateCreated = new DateTime(2010, 6, 20), AccountType = "Foreign", OwnerId = Guid.Parse("a3c1880c-674c-4d18-8f91-5d3608a2c937") } + ]; +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer.sln b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer.sln new file mode 100644 index 000000000..3bd7f01da --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer.sln @@ -0,0 +1,104 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AccountOwnerServer", "AccountOwnerServer\AccountOwnerServer.csproj", "{65C2DEA3-71F9-4F60-9B2B-7E4899FD25D8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LoggerService", "LoggerService\LoggerService.csproj", "{D347A258-1B00-447F-8A01-D6DB6215DBF9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contracts", "Contracts\Contracts.csproj", "{9E985092-73A6-4A7B-B71E-DA22D96456AE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Entities", "Entities\Entities.csproj", "{DA32AC0E-3E93-48C7-9F99-5822EA0AE692}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Repository", "Repository\Repository.csproj", "{BF0DE019-6796-440E-B0F0-C644E3631544}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AccountOwnerServer.Tests", "AccountOwnerServer.Tests\AccountOwnerServer.Tests.csproj", "{0F167183-CB75-49F2-B781-027542075B7D}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {65C2DEA3-71F9-4F60-9B2B-7E4899FD25D8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {65C2DEA3-71F9-4F60-9B2B-7E4899FD25D8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {65C2DEA3-71F9-4F60-9B2B-7E4899FD25D8}.Debug|x64.ActiveCfg = Debug|Any CPU + {65C2DEA3-71F9-4F60-9B2B-7E4899FD25D8}.Debug|x64.Build.0 = Debug|Any CPU + {65C2DEA3-71F9-4F60-9B2B-7E4899FD25D8}.Debug|x86.ActiveCfg = Debug|Any CPU + {65C2DEA3-71F9-4F60-9B2B-7E4899FD25D8}.Debug|x86.Build.0 = Debug|Any CPU + {65C2DEA3-71F9-4F60-9B2B-7E4899FD25D8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {65C2DEA3-71F9-4F60-9B2B-7E4899FD25D8}.Release|Any CPU.Build.0 = Release|Any CPU + {65C2DEA3-71F9-4F60-9B2B-7E4899FD25D8}.Release|x64.ActiveCfg = Release|Any CPU + {65C2DEA3-71F9-4F60-9B2B-7E4899FD25D8}.Release|x64.Build.0 = Release|Any CPU + {65C2DEA3-71F9-4F60-9B2B-7E4899FD25D8}.Release|x86.ActiveCfg = Release|Any CPU + {65C2DEA3-71F9-4F60-9B2B-7E4899FD25D8}.Release|x86.Build.0 = Release|Any CPU + {D347A258-1B00-447F-8A01-D6DB6215DBF9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D347A258-1B00-447F-8A01-D6DB6215DBF9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D347A258-1B00-447F-8A01-D6DB6215DBF9}.Debug|x64.ActiveCfg = Debug|Any CPU + {D347A258-1B00-447F-8A01-D6DB6215DBF9}.Debug|x64.Build.0 = Debug|Any CPU + {D347A258-1B00-447F-8A01-D6DB6215DBF9}.Debug|x86.ActiveCfg = Debug|Any CPU + {D347A258-1B00-447F-8A01-D6DB6215DBF9}.Debug|x86.Build.0 = Debug|Any CPU + {D347A258-1B00-447F-8A01-D6DB6215DBF9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D347A258-1B00-447F-8A01-D6DB6215DBF9}.Release|Any CPU.Build.0 = Release|Any CPU + {D347A258-1B00-447F-8A01-D6DB6215DBF9}.Release|x64.ActiveCfg = Release|Any CPU + {D347A258-1B00-447F-8A01-D6DB6215DBF9}.Release|x64.Build.0 = Release|Any CPU + {D347A258-1B00-447F-8A01-D6DB6215DBF9}.Release|x86.ActiveCfg = Release|Any CPU + {D347A258-1B00-447F-8A01-D6DB6215DBF9}.Release|x86.Build.0 = Release|Any CPU + {9E985092-73A6-4A7B-B71E-DA22D96456AE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9E985092-73A6-4A7B-B71E-DA22D96456AE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9E985092-73A6-4A7B-B71E-DA22D96456AE}.Debug|x64.ActiveCfg = Debug|Any CPU + {9E985092-73A6-4A7B-B71E-DA22D96456AE}.Debug|x64.Build.0 = Debug|Any CPU + {9E985092-73A6-4A7B-B71E-DA22D96456AE}.Debug|x86.ActiveCfg = Debug|Any CPU + {9E985092-73A6-4A7B-B71E-DA22D96456AE}.Debug|x86.Build.0 = Debug|Any CPU + {9E985092-73A6-4A7B-B71E-DA22D96456AE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9E985092-73A6-4A7B-B71E-DA22D96456AE}.Release|Any CPU.Build.0 = Release|Any CPU + {9E985092-73A6-4A7B-B71E-DA22D96456AE}.Release|x64.ActiveCfg = Release|Any CPU + {9E985092-73A6-4A7B-B71E-DA22D96456AE}.Release|x64.Build.0 = Release|Any CPU + {9E985092-73A6-4A7B-B71E-DA22D96456AE}.Release|x86.ActiveCfg = Release|Any CPU + {9E985092-73A6-4A7B-B71E-DA22D96456AE}.Release|x86.Build.0 = Release|Any CPU + {DA32AC0E-3E93-48C7-9F99-5822EA0AE692}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DA32AC0E-3E93-48C7-9F99-5822EA0AE692}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DA32AC0E-3E93-48C7-9F99-5822EA0AE692}.Debug|x64.ActiveCfg = Debug|Any CPU + {DA32AC0E-3E93-48C7-9F99-5822EA0AE692}.Debug|x64.Build.0 = Debug|Any CPU + {DA32AC0E-3E93-48C7-9F99-5822EA0AE692}.Debug|x86.ActiveCfg = Debug|Any CPU + {DA32AC0E-3E93-48C7-9F99-5822EA0AE692}.Debug|x86.Build.0 = Debug|Any CPU + {DA32AC0E-3E93-48C7-9F99-5822EA0AE692}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DA32AC0E-3E93-48C7-9F99-5822EA0AE692}.Release|Any CPU.Build.0 = Release|Any CPU + {DA32AC0E-3E93-48C7-9F99-5822EA0AE692}.Release|x64.ActiveCfg = Release|Any CPU + {DA32AC0E-3E93-48C7-9F99-5822EA0AE692}.Release|x64.Build.0 = Release|Any CPU + {DA32AC0E-3E93-48C7-9F99-5822EA0AE692}.Release|x86.ActiveCfg = Release|Any CPU + {DA32AC0E-3E93-48C7-9F99-5822EA0AE692}.Release|x86.Build.0 = Release|Any CPU + {BF0DE019-6796-440E-B0F0-C644E3631544}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BF0DE019-6796-440E-B0F0-C644E3631544}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BF0DE019-6796-440E-B0F0-C644E3631544}.Debug|x64.ActiveCfg = Debug|Any CPU + {BF0DE019-6796-440E-B0F0-C644E3631544}.Debug|x64.Build.0 = Debug|Any CPU + {BF0DE019-6796-440E-B0F0-C644E3631544}.Debug|x86.ActiveCfg = Debug|Any CPU + {BF0DE019-6796-440E-B0F0-C644E3631544}.Debug|x86.Build.0 = Debug|Any CPU + {BF0DE019-6796-440E-B0F0-C644E3631544}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BF0DE019-6796-440E-B0F0-C644E3631544}.Release|Any CPU.Build.0 = Release|Any CPU + {BF0DE019-6796-440E-B0F0-C644E3631544}.Release|x64.ActiveCfg = Release|Any CPU + {BF0DE019-6796-440E-B0F0-C644E3631544}.Release|x64.Build.0 = Release|Any CPU + {BF0DE019-6796-440E-B0F0-C644E3631544}.Release|x86.ActiveCfg = Release|Any CPU + {BF0DE019-6796-440E-B0F0-C644E3631544}.Release|x86.Build.0 = Release|Any CPU + {0F167183-CB75-49F2-B781-027542075B7D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0F167183-CB75-49F2-B781-027542075B7D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0F167183-CB75-49F2-B781-027542075B7D}.Debug|x64.ActiveCfg = Debug|Any CPU + {0F167183-CB75-49F2-B781-027542075B7D}.Debug|x64.Build.0 = Debug|Any CPU + {0F167183-CB75-49F2-B781-027542075B7D}.Debug|x86.ActiveCfg = Debug|Any CPU + {0F167183-CB75-49F2-B781-027542075B7D}.Debug|x86.Build.0 = Debug|Any CPU + {0F167183-CB75-49F2-B781-027542075B7D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0F167183-CB75-49F2-B781-027542075B7D}.Release|Any CPU.Build.0 = Release|Any CPU + {0F167183-CB75-49F2-B781-027542075B7D}.Release|x64.ActiveCfg = Release|Any CPU + {0F167183-CB75-49F2-B781-027542075B7D}.Release|x64.Build.0 = Release|Any CPU + {0F167183-CB75-49F2-B781-027542075B7D}.Release|x86.ActiveCfg = Release|Any CPU + {0F167183-CB75-49F2-B781-027542075B7D}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer/AccountOwnerServer.csproj b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer/AccountOwnerServer.csproj new file mode 100644 index 000000000..8d9f2e4a6 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer/AccountOwnerServer.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + enable + + + + + + + + + + + + diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer/Controllers/AccountController.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer/Controllers/AccountController.cs new file mode 100644 index 000000000..ca0df4724 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer/Controllers/AccountController.cs @@ -0,0 +1,62 @@ +using Contracts; +using Entities.Extensions; +using Entities.Models; +using Microsoft.AspNetCore.Mvc; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using System; + +namespace AccountOwnerServer.Controllers; + +[Route("api/owners/{ownerId}/accounts")] +[ApiController] +public class AccountController : ControllerBase +{ + private readonly ILoggerManager _logger; + private readonly IRepositoryWrapper _repository; + + public AccountController(ILoggerManager logger, + IRepositoryWrapper repository) + { + _logger = logger; + _repository = repository; + } + + [HttpGet] + public async Task>> GetAccountsForOwner(Guid ownerId, [FromQuery] AccountParameters parameters) + { + var accounts = await _repository.Account.GetAccountsByOwner(ownerId, parameters); + + var metadata = new + { + accounts.TotalCount, + accounts.PageSize, + accounts.CurrentPage, + accounts.TotalPages, + accounts.HasNext, + accounts.HasPrevious + }; + + Response.Headers["X-Pagination"] = JsonSerializer.Serialize(metadata); + + _logger.LogInfo($"Returned {accounts.TotalCount} accounts from database."); + + return Ok(accounts); + } + + [HttpGet("{id}")] + public IActionResult GetAccountForOwner(Guid ownerId, Guid id, [FromQuery] string? fields) + { + var account = _repository.Account.GetAccountByOwner(ownerId, id); + + if (account.IsObjectNull()) + { + _logger.LogError($"Account with id: {id}, hasn't been found in db."); + return NotFound(); + } + + return Ok(_repository.Account.GetAccountByOwner(ownerId, id, fields)); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer/Controllers/OwnerController.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer/Controllers/OwnerController.cs new file mode 100644 index 000000000..09bdd09b6 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer/Controllers/OwnerController.cs @@ -0,0 +1,137 @@ +using Contracts; +using Entities.Extensions; +using Entities.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using System; + +namespace AccountOwnerServer.Controllers; + +[Route("api/owners")] +[ApiController] +public class OwnerController : ControllerBase +{ + private readonly ILoggerManager _logger; + private readonly IRepositoryWrapper _repository; + + public OwnerController(ILoggerManager logger, + IRepositoryWrapper repository) + { + _logger = logger; + _repository = repository; + } + + [HttpGet] + public async Task>> GetOwners([FromQuery] OwnerParameters ownerParameters) + { + if (!ownerParameters.ValidYearRange) + { + return Problem( + detail: "maxYearOfBirth must be greater than or equal to minYearOfBirth.", + statusCode: StatusCodes.Status400BadRequest); + } + + var owners = await _repository.Owner.GetOwners(ownerParameters); + + var metadata = new + { + owners.TotalCount, + owners.PageSize, + owners.CurrentPage, + owners.TotalPages, + owners.HasNext, + owners.HasPrevious + }; + + Response.Headers["X-Pagination"] = JsonSerializer.Serialize(metadata); + + _logger.LogInfo($"Returned {owners.TotalCount} owners from database."); + + return Ok(owners); + } + + [HttpGet("{id}", Name = "OwnerById")] + public IActionResult GetOwnerById(Guid id, [FromQuery] string? fields) + { + var dbOwner = _repository.Owner.GetOwnerById(id); + + if (dbOwner.IsEmptyObject()) + { + _logger.LogError($"Owner with id: {id}, hasn't been found in db."); + return NotFound(); + } + + return Ok(_repository.Owner.GetOwnerById(id, fields)); + } + + [HttpPost] + public IActionResult CreateOwner([FromBody] Owner owner) + { + if (owner.IsObjectNull()) + { + _logger.LogError("Owner object sent from client is null."); + return BadRequest("Owner object is null"); + } + + if (!ModelState.IsValid) + { + _logger.LogError("Invalid owner object sent from client."); + return BadRequest("Invalid model object"); + } + + _repository.Owner.CreateOwner(owner); + _repository.Save(); + + return CreatedAtRoute("OwnerById", new { id = owner.Id }, owner); + } + + [HttpPut("{id}")] + public IActionResult UpdateOwner(Guid id, [FromBody] Owner owner) + { + if (owner.IsObjectNull()) + { + _logger.LogError("Owner object sent from client is null."); + return BadRequest("Owner object is null"); + } + + if (!ModelState.IsValid) + { + _logger.LogError("Invalid owner object sent from client."); + return BadRequest("Invalid model object"); + } + + var dbOwner = _repository.Owner.GetOwnerById(id); + + if (dbOwner.IsEmptyObject()) + { + _logger.LogError($"Owner with id: {id}, hasn't been found in db."); + return NotFound(); + } + + _repository.Owner.UpdateOwner(dbOwner, owner); + _repository.Save(); + + return NoContent(); + } + + [HttpDelete("{id}")] + public IActionResult DeleteOwner(Guid id) + { + var owner = _repository.Owner.GetOwnerById(id); + + if (owner.IsEmptyObject()) + { + _logger.LogError($"Owner with id: {id}, hasn't been found in db."); + return NotFound(); + } + + _repository.Owner.DeleteOwner(owner); + _repository.Save(); + + return NoContent(); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer/Extensions/ServiceExtensions.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer/Extensions/ServiceExtensions.cs new file mode 100644 index 000000000..62a069134 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer/Extensions/ServiceExtensions.cs @@ -0,0 +1,42 @@ +using Contracts; +using Entities.Helpers; +using Entities.Models; +using Entities; +using LoggerService; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Repository; + +namespace AccountOwnerServer.Extensions; + +public static class ServiceExtensions +{ + public static void ConfigureCors(this IServiceCollection services) => + services.AddCors(options => + options.AddPolicy("CorsPolicy", + builder => builder.WithOrigins("http://localhost:5000", "https://localhost:5001") + .AllowAnyMethod() + .AllowAnyHeader() + .AllowCredentials() + .WithExposedHeaders("X-Pagination"))); + + public static void ConfigureLoggerService(this IServiceCollection services) => + services.AddSingleton(); + + public static void ConfigureSqlContext(this IServiceCollection services, IConfiguration config) + { + var connectionString = config["sqlconnection:connectionString"]; + services.AddDbContext(o => o.UseSqlServer(connectionString)); + } + + public static void ConfigureRepositoryWrapper(this IServiceCollection services) + { + services.AddScoped, SortHelper>(); + services.AddScoped, SortHelper>(); + + services.AddSingleton, DataShaper>(); + services.AddSingleton, DataShaper>(); + services.AddScoped(); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer/Program.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer/Program.cs new file mode 100644 index 000000000..0739f0183 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer/Program.cs @@ -0,0 +1,64 @@ +using AccountOwnerServer.Extensions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using NLog; +using System.Net; + +LogManager.Setup().LoadConfigurationFromFile("nlog.config"); + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.ConfigureCors(); +builder.Services.ConfigureLoggerService(); +builder.Services.ConfigureSqlContext(builder.Configuration); +builder.Services.ConfigureRepositoryWrapper(); + +builder.Services.AddControllers(config => +{ + config.RespectBrowserAcceptHeader = true; + config.ReturnHttpNotAcceptable = true; +}).AddXmlDataContractSerializerFormatters(); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) + app.UseDeveloperExceptionPage(); +else + app.UseHsts(); + +app.UseExceptionHandler(appError => +{ + appError.Run(async context => + { + context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; + context.Response.ContentType = "application/json"; + + var contextFeature = context.Features.Get(); + + if (contextFeature is not null) + { + await context.Response.WriteAsJsonAsync(new + { + context.Response.StatusCode, + Message = "Internal Server Error." + }); + } + }); +}); + +app.UseHttpsRedirection(); + +app.UseCors("CorsPolicy"); + +app.UseForwardedHeaders(new ForwardedHeadersOptions +{ + ForwardedHeaders = ForwardedHeaders.All +}); + +app.MapControllers(); + +app.Run(); diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer/Properties/launchSettings.json b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer/Properties/launchSettings.json new file mode 100644 index 000000000..a82b9223a --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "AccountOwnerServer": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "api/owners", + "applicationUrl": "https://localhost:5001;http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer/appsettings.Development.json b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer/appsettings.Development.json new file mode 100644 index 000000000..e203e9407 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + } + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer/appsettings.json b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer/appsettings.json new file mode 100644 index 000000000..0096cb3d4 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer/appsettings.json @@ -0,0 +1,11 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning" + } + }, + "sqlconnection": { + "connectionString": "Server=(localdb)\\mssqllocaldb;Database=AccountOwner;Trusted_Connection=True;TrustServerCertificate=True;" + }, + "AllowedHosts": "*" +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer/nlog.config b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer/nlog.config new file mode 100644 index 000000000..52f675aa6 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/AccountOwnerServer/nlog.config @@ -0,0 +1,17 @@ + + + + + + + + + + + diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Contracts/Contracts.csproj b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Contracts/Contracts.csproj new file mode 100644 index 000000000..b201bbf27 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Contracts/Contracts.csproj @@ -0,0 +1,12 @@ + + + + net10.0 + enable + + + + + + + diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Contracts/IAccountRepository.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Contracts/IAccountRepository.cs new file mode 100644 index 000000000..5c8d00177 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Contracts/IAccountRepository.cs @@ -0,0 +1,13 @@ +using Entities.Helpers; +using Entities.Models; +using System; +using System.Threading.Tasks; + +namespace Contracts; + +public interface IAccountRepository : IRepositoryBase +{ + Task> GetAccountsByOwner(Guid ownerId, AccountParameters parameters); + Entity GetAccountByOwner(Guid ownerId, Guid id, string? fields); + Account? GetAccountByOwner(Guid ownerId, Guid id); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Contracts/ILoggerManager.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Contracts/ILoggerManager.cs new file mode 100644 index 000000000..42d30745d --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Contracts/ILoggerManager.cs @@ -0,0 +1,9 @@ +namespace Contracts; + +public interface ILoggerManager +{ + void LogInfo(string message); + void LogWarn(string message); + void LogDebug(string message); + void LogError(string message); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Contracts/IOwnerRepository.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Contracts/IOwnerRepository.cs new file mode 100644 index 000000000..8b29e5331 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Contracts/IOwnerRepository.cs @@ -0,0 +1,16 @@ +using Entities.Helpers; +using Entities.Models; +using System; +using System.Threading.Tasks; + +namespace Contracts; + +public interface IOwnerRepository : IRepositoryBase +{ + Task> GetOwners(OwnerParameters ownerParameters); + Entity GetOwnerById(Guid ownerId, string? fields); + Owner GetOwnerById(Guid ownerId); + void CreateOwner(Owner owner); + void UpdateOwner(Owner dbOwner, Owner owner); + void DeleteOwner(Owner owner); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Contracts/IRepositoryBase.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Contracts/IRepositoryBase.cs new file mode 100644 index 000000000..51283872d --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Contracts/IRepositoryBase.cs @@ -0,0 +1,14 @@ +using System; +using System.Linq; +using System.Linq.Expressions; + +namespace Contracts; + +public interface IRepositoryBase +{ + IQueryable FindAll(); + IQueryable FindByCondition(Expression> expression); + void Create(T entity); + void Update(T entity); + void Delete(T entity); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Contracts/IRepositoryWrapper.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Contracts/IRepositoryWrapper.cs new file mode 100644 index 000000000..b7dd600fe --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Contracts/IRepositoryWrapper.cs @@ -0,0 +1,8 @@ +namespace Contracts; + +public interface IRepositoryWrapper +{ + IOwnerRepository Owner { get; } + IAccountRepository Account { get; } + void Save(); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/Entities.csproj b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/Entities.csproj new file mode 100644 index 000000000..4aa0cf347 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/Entities.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + + + + + + + + diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/Enumerations/AccountType.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/Enumerations/AccountType.cs new file mode 100644 index 000000000..b84e0ad48 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/Enumerations/AccountType.cs @@ -0,0 +1,8 @@ +namespace Entities.Enumerations; + +public enum AccountType +{ + Domestic, + Savings, + Foreign +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/ExtendedModels/OwnerExtended.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/ExtendedModels/OwnerExtended.cs new file mode 100644 index 000000000..b42caa4c4 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/ExtendedModels/OwnerExtended.cs @@ -0,0 +1,27 @@ +using Entities.Models; +using System; +using System.Collections.Generic; + +namespace Entities.ExtendedModels; + +public class OwnerExtended : IEntity +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public DateTime DateOfBirth { get; set; } + public string Address { get; set; } = string.Empty; + + public IEnumerable Accounts { get; set; } = []; + + public OwnerExtended() + { + } + + public OwnerExtended(Owner owner) + { + Id = owner.Id; + Name = owner.Name; + DateOfBirth = owner.DateOfBirth; + Address = owner.Address; + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/Extensions/IEntityExtensions.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/Extensions/IEntityExtensions.cs new file mode 100644 index 000000000..243eafc70 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/Extensions/IEntityExtensions.cs @@ -0,0 +1,10 @@ +using System; + +namespace Entities.Extensions; + +public static class IEntityExtensions +{ + public static bool IsObjectNull(this IEntity? entity) => entity is null; + + public static bool IsEmptyObject(this IEntity entity) => entity.Id.Equals(Guid.Empty); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/Extensions/OwnerExtensions.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/Extensions/OwnerExtensions.cs new file mode 100644 index 000000000..ba2768266 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/Extensions/OwnerExtensions.cs @@ -0,0 +1,13 @@ +using Entities.Models; + +namespace Entities.Extensions; + +public static class OwnerExtensions +{ + public static void Map(this Owner dbOwner, Owner owner) + { + dbOwner.Name = owner.Name; + dbOwner.Address = owner.Address; + dbOwner.DateOfBirth = owner.DateOfBirth; + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/Helpers/DataShaper.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/Helpers/DataShaper.cs new file mode 100644 index 000000000..9236f85c3 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/Helpers/DataShaper.cs @@ -0,0 +1,63 @@ +using Entities.Models; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace Entities.Helpers; + +public class DataShaper : IDataShaper +{ + private readonly PropertyInfo[] _properties; + + public DataShaper() + { + _properties = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance); + } + + public IEnumerable ShapeData(IEnumerable entities, string? fieldsString) + { + var requiredProperties = GetRequiredProperties(fieldsString); + + return entities.Select(entity => FetchDataForEntity(entity, requiredProperties)).ToList(); + } + + public Entity ShapeData(T entity, string? fieldsString) + { + var requiredProperties = GetRequiredProperties(fieldsString); + + return FetchDataForEntity(entity, requiredProperties); + } + + private IEnumerable GetRequiredProperties(string? fieldsString) + { + if (string.IsNullOrWhiteSpace(fieldsString)) + return _properties; + + var requiredProperties = new List(); + var fields = fieldsString.Split(',', StringSplitOptions.RemoveEmptyEntries); + + foreach (var field in fields) + { + var property = _properties.FirstOrDefault(pi => + pi.Name.Equals(field.Trim(), StringComparison.OrdinalIgnoreCase)); + + if (property is null) + continue; + + requiredProperties.Add(property); + } + + return requiredProperties; + } + + private static Entity FetchDataForEntity(T entity, IEnumerable requiredProperties) + { + var shapedObject = new Entity(); + + foreach (var property in requiredProperties) + shapedObject.TryAdd(property.Name, property.GetValue(entity)!); + + return shapedObject; + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/Helpers/IDataShaper.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/Helpers/IDataShaper.cs new file mode 100644 index 000000000..0fe3ce53e --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/Helpers/IDataShaper.cs @@ -0,0 +1,10 @@ +using Entities.Models; +using System.Collections.Generic; + +namespace Entities.Helpers; + +public interface IDataShaper +{ + IEnumerable ShapeData(IEnumerable entities, string? fieldsString); + Entity ShapeData(T entity, string? fieldsString); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/Helpers/ISortHelper.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/Helpers/ISortHelper.cs new file mode 100644 index 000000000..3d867c508 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/Helpers/ISortHelper.cs @@ -0,0 +1,8 @@ +using System.Linq; + +namespace Entities.Helpers; + +public interface ISortHelper +{ + IQueryable ApplySort(IQueryable entities, string? orderByQueryString); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/Helpers/PagedList.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/Helpers/PagedList.cs new file mode 100644 index 000000000..a81287b98 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/Helpers/PagedList.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; + +namespace Entities.Helpers; + +public class PagedList : List +{ + public int CurrentPage { get; private set; } + public int TotalPages { get; private set; } + public int PageSize { get; private set; } + public int TotalCount { get; private set; } + + public bool HasPrevious => CurrentPage > 1; + public bool HasNext => CurrentPage < TotalPages; + + public PagedList() + { + } + + public PagedList(List items, int count, int pageNumber, int pageSize) + { + TotalCount = count; + PageSize = pageSize; + CurrentPage = pageNumber; + TotalPages = (int)Math.Ceiling(count / (double)pageSize); + + AddRange(items); + } + + public static async Task> ToPagedListAsync(IQueryable source, int pageNumber, int pageSize) + { + var count = await source.CountAsync(); + var items = await source.Skip((pageNumber - 1) * pageSize).Take(pageSize).ToListAsync(); + + return new PagedList(items, count, pageNumber, pageSize); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/Helpers/SortHelper.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/Helpers/SortHelper.cs new file mode 100644 index 000000000..705154cbe --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/Helpers/SortHelper.cs @@ -0,0 +1,46 @@ +using System; +using System.Linq; +using System.Linq.Dynamic.Core; +using System.Reflection; +using System.Text; + +namespace Entities.Helpers; + +public class SortHelper : ISortHelper +{ + public IQueryable ApplySort(IQueryable entities, string? orderByQueryString) + { + if (string.IsNullOrWhiteSpace(orderByQueryString)) + return entities; + + var orderParams = orderByQueryString.Trim().Split(','); + var propertyInfos = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance); + var orderQueryBuilder = new StringBuilder(); + + foreach (var param in orderParams) + { + if (string.IsNullOrWhiteSpace(param)) + continue; + + var propertyFromQueryName = param.Split(' ')[0]; + var objectProperty = propertyInfos.FirstOrDefault(pi => + pi.Name.Equals(propertyFromQueryName, StringComparison.InvariantCultureIgnoreCase)); + + if (objectProperty is null) + continue; + + var sortingOrder = param.EndsWith(" desc", StringComparison.OrdinalIgnoreCase) + ? "descending" + : "ascending"; + + orderQueryBuilder.Append($"{objectProperty.Name} {sortingOrder}, "); + } + + var orderQuery = orderQueryBuilder.ToString().TrimEnd(',', ' '); + + if (string.IsNullOrWhiteSpace(orderQuery)) + return entities; + + return entities.OrderBy(orderQuery); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/IEntity.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/IEntity.cs new file mode 100644 index 000000000..825a8525b --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/IEntity.cs @@ -0,0 +1,8 @@ +using System; + +namespace Entities; + +public interface IEntity +{ + Guid Id { get; set; } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/Models/Account.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/Models/Account.cs new file mode 100644 index 000000000..7a5590d9f --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/Models/Account.cs @@ -0,0 +1,22 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Entities.Models; + +[Table("account")] +public class Account : IEntity +{ + [Key] + [Column("AccountId")] + public Guid Id { get; set; } + + [Required(ErrorMessage = "Date created is required")] + public DateTime DateCreated { get; set; } + + [Required(ErrorMessage = "Account type is required")] + public string AccountType { get; set; } = string.Empty; + + [Required(ErrorMessage = "Owner Id is required")] + public Guid OwnerId { get; set; } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/Models/AccountParameters.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/Models/AccountParameters.cs new file mode 100644 index 000000000..448b50178 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/Models/AccountParameters.cs @@ -0,0 +1,9 @@ +namespace Entities.Models; + +public class AccountParameters : QueryStringParameters +{ + public AccountParameters() + { + OrderBy = "DateCreated"; + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/Models/Entity.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/Models/Entity.cs new file mode 100644 index 000000000..a6d222acc --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/Models/Entity.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Dynamic; +using System.Xml; +using System.Xml.Schema; +using System.Xml.Serialization; + +namespace Entities.Models; + +public class Entity : DynamicObject, IXmlSerializable, IDictionary +{ + private readonly string _root = "Entity"; + private readonly IDictionary _expando; + + public Entity() + { + _expando = new ExpandoObject()!; + } + + public override bool TryGetMember(GetMemberBinder binder, out object? result) + { + if (_expando.TryGetValue(binder.Name, out var value)) + { + result = value; + return true; + } + + return base.TryGetMember(binder, out result); + } + + public override bool TrySetMember(SetMemberBinder binder, object? value) + { + _expando[binder.Name] = value!; + + return true; + } + + public XmlSchema? GetSchema() => null; + + public void ReadXml(XmlReader reader) + { + reader.ReadStartElement(_root); + + while (!reader.Name.Equals(_root)) + { + var name = reader.Name; + + reader.MoveToAttribute("type"); + var typeContent = reader.ReadContentAsString(); + var underlyingType = Type.GetType(typeContent); + reader.MoveToContent(); + _expando[name] = reader.ReadElementContentAs(underlyingType!, null!)!; + } + } + + public void WriteXml(XmlWriter writer) + { + foreach (var key in _expando.Keys) + WriteXmlElement(key, _expando[key], writer); + } + + private void WriteXmlElement(string key, object value, XmlWriter writer) + { + writer.WriteStartElement(key); + writer.WriteString(value.ToString()); + writer.WriteEndElement(); + } + + public void Add(string key, object value) => _expando.Add(key, value); + + public bool ContainsKey(string key) => _expando.ContainsKey(key); + + public ICollection Keys => _expando.Keys; + + public bool Remove(string key) => _expando.Remove(key); + + public bool TryGetValue(string key, out object value) => _expando.TryGetValue(key, out value!); + + public ICollection Values => _expando.Values; + + public object this[string key] + { + get => _expando[key]; + set => _expando[key] = value; + } + + public void Add(KeyValuePair item) => _expando.Add(item); + + public void Clear() => _expando.Clear(); + + public bool Contains(KeyValuePair item) => _expando.Contains(item); + + public void CopyTo(KeyValuePair[] array, int arrayIndex) => _expando.CopyTo(array, arrayIndex); + + public int Count => _expando.Count; + + public bool IsReadOnly => _expando.IsReadOnly; + + public bool Remove(KeyValuePair item) => _expando.Remove(item); + + public IEnumerator> GetEnumerator() => _expando.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/Models/Owner.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/Models/Owner.cs new file mode 100644 index 000000000..05b422d00 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/Models/Owner.cs @@ -0,0 +1,24 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Entities.Models; + +[Table("owner")] +public class Owner : IEntity +{ + [Key] + [Column("OwnerId")] + public Guid Id { get; set; } + + [Required(ErrorMessage = "Name is required")] + [StringLength(60, ErrorMessage = "Name can't be longer than 60 characters")] + public string Name { get; set; } = string.Empty; + + [Required(ErrorMessage = "Date of birth is required")] + public DateTime DateOfBirth { get; set; } + + [Required(ErrorMessage = "Address is required")] + [StringLength(100, ErrorMessage = "Address can't be longer than 100 characters")] + public string Address { get; set; } = string.Empty; +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/Models/OwnerParameters.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/Models/OwnerParameters.cs new file mode 100644 index 000000000..55d4cbe80 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/Models/OwnerParameters.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; + +namespace Entities.Models; + +public class OwnerParameters : QueryStringParameters +{ + public OwnerParameters() + { + OrderBy = "name"; + } + + [Range(1900, 2100)] + public int? MinYearOfBirth { get; set; } + + [Range(1900, 2100)] + public int? MaxYearOfBirth { get; set; } + + public bool ValidYearRange => + MinYearOfBirth is null || MaxYearOfBirth is null || MaxYearOfBirth >= MinYearOfBirth; + + public string? SearchTerm { get; set; } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/Models/QueryStringParameters.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/Models/QueryStringParameters.cs new file mode 100644 index 000000000..da32d2c6a --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/Models/QueryStringParameters.cs @@ -0,0 +1,27 @@ +using System; + +namespace Entities.Models; + +public abstract class QueryStringParameters +{ + const int MaxPageSize = 50; + + private int _pageNumber = 1; + private int _pageSize = 10; + + public int PageNumber + { + get => _pageNumber; + set => _pageNumber = Math.Max(value, 1); + } + + public int PageSize + { + get => _pageSize; + set => _pageSize = Math.Clamp(value, 1, MaxPageSize); + } + + public string? OrderBy { get; set; } + + public string? Fields { get; set; } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/RepositoryContext.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/RepositoryContext.cs new file mode 100644 index 000000000..1857e8e5f --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Entities/RepositoryContext.cs @@ -0,0 +1,15 @@ +using Entities.Models; +using Microsoft.EntityFrameworkCore; + +namespace Entities; + +public class RepositoryContext : DbContext +{ + public RepositoryContext(DbContextOptions options) + : base(options) + { + } + + public DbSet Owners { get; set; } = null!; + public DbSet Accounts { get; set; } = null!; +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/LoggerService/LoggerManager.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/LoggerService/LoggerManager.cs new file mode 100644 index 000000000..dd71d82d6 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/LoggerService/LoggerManager.cs @@ -0,0 +1,17 @@ +using Contracts; +using NLog; + +namespace LoggerService; + +public class LoggerManager : ILoggerManager +{ + private static readonly ILogger Logger = LogManager.GetCurrentClassLogger(); + + public void LogDebug(string message) => Logger.Debug(message); + + public void LogError(string message) => Logger.Error(message); + + public void LogInfo(string message) => Logger.Info(message); + + public void LogWarn(string message) => Logger.Warn(message); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/LoggerService/LoggerService.csproj b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/LoggerService/LoggerService.csproj new file mode 100644 index 000000000..8adf826f0 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/LoggerService/LoggerService.csproj @@ -0,0 +1,16 @@ + + + + net10.0 + enable + + + + + + + + + + + diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Repository/AccountRepository.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Repository/AccountRepository.cs new file mode 100644 index 000000000..14a748479 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Repository/AccountRepository.cs @@ -0,0 +1,48 @@ +using Contracts; +using Entities; +using Entities.Helpers; +using Entities.Models; +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace Repository; + +public class AccountRepository : RepositoryBase, IAccountRepository +{ + private readonly ISortHelper _sortHelper; + private readonly IDataShaper _dataShaper; + + public AccountRepository(RepositoryContext repositoryContext, + ISortHelper sortHelper, + IDataShaper dataShaper) + : base(repositoryContext) + { + _sortHelper = sortHelper; + _dataShaper = dataShaper; + } + + public async Task> GetAccountsByOwner(Guid ownerId, AccountParameters parameters) + { + var accounts = FindByCondition(a => a.OwnerId.Equals(ownerId)); + + var sortedAccounts = _sortHelper.ApplySort(accounts.OrderBy(a => a.DateCreated), parameters.OrderBy); + + var pagedAccounts = await PagedList.ToPagedListAsync(sortedAccounts, + parameters.PageNumber, + parameters.PageSize); + + var shapedAccounts = _dataShaper.ShapeData(pagedAccounts, parameters.Fields).ToList(); + + return new PagedList(shapedAccounts, + pagedAccounts.TotalCount, + pagedAccounts.CurrentPage, + pagedAccounts.PageSize); + } + + public Entity GetAccountByOwner(Guid ownerId, Guid id, string? fields) => + _dataShaper.ShapeData(GetAccountByOwner(ownerId, id) ?? new Account(), fields); + + public Account? GetAccountByOwner(Guid ownerId, Guid id) => + FindByCondition(a => a.OwnerId.Equals(ownerId) && a.Id.Equals(id)).SingleOrDefault(); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Repository/Extensions/RepositoryOwnerExtensions.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Repository/Extensions/RepositoryOwnerExtensions.cs new file mode 100644 index 000000000..abf4cd02e --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Repository/Extensions/RepositoryOwnerExtensions.cs @@ -0,0 +1,17 @@ +using Entities.Models; +using System.Linq; + +namespace Repository.Extensions; + +public static class RepositoryOwnerExtensions +{ + public static IQueryable Search(this IQueryable owners, string? searchTerm) + { + if (string.IsNullOrWhiteSpace(searchTerm)) + return owners; + + var term = searchTerm.Trim(); + + return owners.Where(o => o.Name.Contains(term) || o.Address.Contains(term)); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Repository/OwnerRepository.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Repository/OwnerRepository.cs new file mode 100644 index 000000000..1d3677c61 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Repository/OwnerRepository.cs @@ -0,0 +1,68 @@ +using Contracts; +using Entities; +using Entities.Extensions; +using Entities.Helpers; +using Entities.Models; +using Repository.Extensions; +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace Repository; + +public class OwnerRepository : RepositoryBase, IOwnerRepository +{ + private readonly ISortHelper _sortHelper; + private readonly IDataShaper _dataShaper; + + public OwnerRepository(RepositoryContext repositoryContext, + ISortHelper sortHelper, + IDataShaper dataShaper) + : base(repositoryContext) + { + _sortHelper = sortHelper; + _dataShaper = dataShaper; + } + + public async Task> GetOwners(OwnerParameters ownerParameters) + { + var owners = FindAll(); + + if (ownerParameters.MinYearOfBirth is { } minYear) + owners = owners.Where(o => o.DateOfBirth >= new DateTime(minYear, 1, 1)); + + if (ownerParameters.MaxYearOfBirth is { } maxYear) + owners = owners.Where(o => o.DateOfBirth < new DateTime(maxYear + 1, 1, 1)); + + owners = owners.Search(ownerParameters.SearchTerm); + + var sortedOwners = _sortHelper.ApplySort(owners.OrderBy(o => o.Name), ownerParameters.OrderBy); + + var pagedOwners = await PagedList.ToPagedListAsync(sortedOwners, + ownerParameters.PageNumber, + ownerParameters.PageSize); + + var shapedOwners = _dataShaper.ShapeData(pagedOwners, ownerParameters.Fields).ToList(); + + return new PagedList(shapedOwners, + pagedOwners.TotalCount, + pagedOwners.CurrentPage, + pagedOwners.PageSize); + } + + public Entity GetOwnerById(Guid ownerId, string? fields) => + _dataShaper.ShapeData(GetOwnerById(ownerId), fields); + + public Owner GetOwnerById(Guid ownerId) => + FindByCondition(owner => owner.Id.Equals(ownerId)).SingleOrDefault() ?? new Owner(); + + public void CreateOwner(Owner owner) => Create(owner); + + public void UpdateOwner(Owner dbOwner, Owner owner) + { + dbOwner.Map(owner); + Update(dbOwner); + } + + public void DeleteOwner(Owner owner) => Delete(owner); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Repository/Repository.csproj b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Repository/Repository.csproj new file mode 100644 index 000000000..61a67779d --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Repository/Repository.csproj @@ -0,0 +1,16 @@ + + + + net10.0 + enable + + + + + + + + + + + diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Repository/RepositoryBase.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Repository/RepositoryBase.cs new file mode 100644 index 000000000..51053f279 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Repository/RepositoryBase.cs @@ -0,0 +1,33 @@ +using Contracts; +using Entities; +using Microsoft.EntityFrameworkCore; +using System; +using System.Linq; +using System.Linq.Expressions; + +namespace Repository; + +public abstract class RepositoryBase : IRepositoryBase where T : class +{ + protected RepositoryContext RepositoryContext { get; set; } + + protected RepositoryBase(RepositoryContext repositoryContext) + { + RepositoryContext = repositoryContext; + } + + public IQueryable FindAll() => + RepositoryContext.Set() + .AsNoTracking(); + + public IQueryable FindByCondition(Expression> expression) => + RepositoryContext.Set() + .Where(expression) + .AsNoTracking(); + + public void Create(T entity) => RepositoryContext.Set().Add(entity); + + public void Update(T entity) => RepositoryContext.Set().Update(entity); + + public void Delete(T entity) => RepositoryContext.Set().Remove(entity); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Repository/RepositoryWrapper.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Repository/RepositoryWrapper.cs new file mode 100644 index 000000000..deedb52ed --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/Repository/RepositoryWrapper.cs @@ -0,0 +1,36 @@ +using Contracts; +using Entities; +using Entities.Helpers; +using Entities.Models; + +namespace Repository; + +public class RepositoryWrapper : IRepositoryWrapper +{ + private readonly RepositoryContext _repoContext; + private readonly ISortHelper _ownerSortHelper; + private readonly ISortHelper _accountSortHelper; + private readonly IDataShaper _ownerDataShaper; + private readonly IDataShaper _accountDataShaper; + private IOwnerRepository? _owner; + private IAccountRepository? _account; + + public RepositoryWrapper(RepositoryContext repositoryContext, + ISortHelper ownerSortHelper, + ISortHelper accountSortHelper, + IDataShaper ownerDataShaper, + IDataShaper accountDataShaper) + { + _repoContext = repositoryContext; + _ownerSortHelper = ownerSortHelper; + _accountSortHelper = accountSortHelper; + _ownerDataShaper = ownerDataShaper; + _accountDataShaper = accountDataShaper; + } + + public IOwnerRepository Owner => _owner ??= new OwnerRepository(_repoContext, _ownerSortHelper, _ownerDataShaper); + + public IAccountRepository Account => _account ??= new AccountRepository(_repoContext, _accountSortHelper, _accountDataShaper); + + public void Save() => _repoContext.SaveChanges(); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/_SqlServer_Init_Script/init.sql b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/_SqlServer_Init_Script/init.sql new file mode 100644 index 000000000..f8232089c --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/DataShaping/_SqlServer_Init_Script/init.sql @@ -0,0 +1,44 @@ +-- SQL Server schema and seed data for the Advanced REST Concepts sample. +-- Run it against a database of your choice, then point sqlconnection:connectionString at it. +-- On Windows the default connection string uses LocalDB. Elsewhere, one line gets you a server: +-- docker run -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=Str0ng!Passw0rd" -p 1433:1433 -d mcr.microsoft.com/mssql/server:2022-latest + +IF OBJECT_ID('dbo.account', 'U') IS NOT NULL DROP TABLE dbo.account; +IF OBJECT_ID('dbo.owner', 'U') IS NOT NULL DROP TABLE dbo.owner; + +CREATE TABLE dbo.owner +( + OwnerId uniqueidentifier NOT NULL PRIMARY KEY, + Name nvarchar(60) NOT NULL, + DateOfBirth datetime2 NOT NULL, + Address nvarchar(100) NOT NULL +); + +CREATE TABLE dbo.account +( + AccountId uniqueidentifier NOT NULL PRIMARY KEY, + DateCreated datetime2 NOT NULL, + AccountType nvarchar(45) NOT NULL, + OwnerId uniqueidentifier NOT NULL + CONSTRAINT fk_account_owner REFERENCES dbo.owner (OwnerId) ON UPDATE CASCADE +); + +CREATE INDEX ix_owner_name ON dbo.owner (Name); +CREATE INDEX ix_owner_dateofbirth ON dbo.owner (DateOfBirth); + +INSERT INTO dbo.owner (OwnerId, Name, DateOfBirth, Address) VALUES + ('24fd81f8-d58a-4bcc-9f35-dc6cd5641906', 'John Keen', '1980-12-05', '61 Wellfield Road'), + ('261e1685-cf26-494c-b17c-3546e65f5620', 'Anna Bosh', '1974-11-14', '27 Colored Row'), + ('66774006-2371-4d5b-8518-2177bcf3f73e', 'Nick Somion', '1998-12-15', 'North sunny address 102'), + ('a3c1880c-674c-4d18-8f91-5d3608a2c937', 'Sam Query', '1990-04-22', '91 Western Roads'), + ('f98e4d74-0f68-4aac-89fd-047f1aaca6b6', 'Martin Miller', '1983-05-21', '3 Edgar Buildings'); + +INSERT INTO dbo.account (AccountId, DateCreated, AccountType, OwnerId) VALUES + ('03e91478-5608-4132-a753-d494dafce00b', '2003-12-15', 'Domestic', 'f98e4d74-0f68-4aac-89fd-047f1aaca6b6'), + ('356a5a9b-64bf-4de0-bc84-5395a1fdc9c4', '1996-02-15', 'Domestic', '261e1685-cf26-494c-b17c-3546e65f5620'), + ('371b93f2-f8c5-4a32-894a-fc672741aa5b', '1999-05-04', 'Domestic', '24fd81f8-d58a-4bcc-9f35-dc6cd5641906'), + ('670775db-ecc0-4b90-a9ab-37cd0d8e2801', '1999-12-21', 'Savings', '24fd81f8-d58a-4bcc-9f35-dc6cd5641906'), + ('a3fbad0b-7f48-4feb-8ac0-6d3bbc997bfc', '2010-05-28', 'Domestic', 'a3c1880c-674c-4d18-8f91-5d3608a2c937'), + ('aa15f658-04bb-4f73-82af-82db49d0fbef', '1999-05-12', 'Foreign', '24fd81f8-d58a-4bcc-9f35-dc6cd5641906'), + ('c6066eb0-53ca-43e1-97aa-3c2169eec659', '1996-02-16', 'Foreign', '261e1685-cf26-494c-b17c-3546e65f5620'), + ('eccadf79-85fe-402f-893c-32d3f03ed9b1', '2010-06-20', 'Foreign', 'a3c1880c-674c-4d18-8f91-5d3608a2c937'); diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/AccountOwnerServer.Tests/AccountOwnerServer.Tests.csproj b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/AccountOwnerServer.Tests/AccountOwnerServer.Tests.csproj new file mode 100644 index 000000000..338bac1ce --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/AccountOwnerServer.Tests/AccountOwnerServer.Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + false + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/AccountOwnerServer.Tests/OwnerParametersTests.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/AccountOwnerServer.Tests/OwnerParametersTests.cs new file mode 100644 index 000000000..4ccdf0adb --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/AccountOwnerServer.Tests/OwnerParametersTests.cs @@ -0,0 +1,41 @@ +using Entities.Models; +using Xunit; + +namespace AccountOwnerServer.Tests; + +public class OwnerParametersTests +{ + [Fact] + public void GivenTheSameMinAndMaxYear_WhenTheRangeIsValidated_ThenItIsAccepted() + { + var parameters = new OwnerParameters { MinYearOfBirth = 1990, MaxYearOfBirth = 1990 }; + + Assert.True(parameters.ValidYearRange); + } + + [Fact] + public void GivenAMaxYearBelowTheMinYear_WhenTheRangeIsValidated_ThenItIsRejected() + { + var parameters = new OwnerParameters { MinYearOfBirth = 1975, MaxYearOfBirth = 1974 }; + + Assert.False(parameters.ValidYearRange); + } + + [Fact] + public void GivenNoYearsAtAll_WhenTheRangeIsValidated_ThenItIsAccepted() + { + var parameters = new OwnerParameters(); + + Assert.Null(parameters.MinYearOfBirth); + Assert.Null(parameters.MaxYearOfBirth); + Assert.True(parameters.ValidYearRange); + } + + [Fact] + public void GivenOnlyAMinYear_WhenTheRangeIsValidated_ThenItIsAccepted() + { + var parameters = new OwnerParameters { MinYearOfBirth = 1975 }; + + Assert.True(parameters.ValidYearRange); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/AccountOwnerServer.Tests/OwnerRepositorySqlServerTests.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/AccountOwnerServer.Tests/OwnerRepositorySqlServerTests.cs new file mode 100644 index 000000000..b53048e90 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/AccountOwnerServer.Tests/OwnerRepositorySqlServerTests.cs @@ -0,0 +1,67 @@ +using Entities.Helpers; +using Entities.Models; +using Repository; +using System; +using System.Linq; +using System.Threading.Tasks; +using Xunit; + +namespace AccountOwnerServer.Tests; + +/// +/// These run against a real SQL Server started by Testcontainers. They are skipped unless +/// CODEMAZE_SQLSERVER_TESTS=1 is set, because the SQL Server image is not guaranteed to be +/// available on every machine or CI runner. +/// +public class OwnerRepositorySqlServerTests +{ + [SqlServerFact] + public async Task GivenAPageSizeOfTwo_WhenWeAskForTheSecondPage_ThenWeGetTwoOwnersAndTheRightMetadata() + { + await using var sample = await SqlServerSample.StartAsync(); + await using var context = sample.CreateContext(); + + var repository = new OwnerRepository(context); + + var owners = await repository.GetOwners(new OwnerParameters { PageNumber = 2, PageSize = 2 }); + + Assert.Equal(2, owners.Count); + Assert.Equal(5, owners.TotalCount); + Assert.Equal(3, owners.TotalPages); + Assert.True(owners.HasPrevious); + Assert.True(owners.HasNext); + } + + [SqlServerFact] + public async Task GivenTheSameMinAndMaxYear_WhenWeFilter_ThenTheOwnerBornInThatYearComesBack() + { + await using var sample = await SqlServerSample.StartAsync(); + await using var context = sample.CreateContext(); + + var repository = new OwnerRepository(context); + + var owners = await repository.GetOwners(new OwnerParameters + { + MinYearOfBirth = 1990, + MaxYearOfBirth = 1990 + }); + + Assert.Equal(1, owners.TotalCount); + Assert.Equal("Sam Query", NameOf(owners.Single())); + } + + [SqlServerFact] + public async Task GivenOnlyAMinimumYear_WhenWeFilter_ThenTheUpperBoundIsOpen() + { + await using var sample = await SqlServerSample.StartAsync(); + await using var context = sample.CreateContext(); + + var repository = new OwnerRepository(context); + + var owners = await repository.GetOwners(new OwnerParameters { MinYearOfBirth = 1990 }); + + Assert.Equal(2, owners.TotalCount); + } + + private static string NameOf(Owner owner) => owner.Name; +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/AccountOwnerServer.Tests/QueryStringParametersTests.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/AccountOwnerServer.Tests/QueryStringParametersTests.cs new file mode 100644 index 000000000..0b3791e6f --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/AccountOwnerServer.Tests/QueryStringParametersTests.cs @@ -0,0 +1,39 @@ +using Entities.Models; +using Xunit; + +namespace AccountOwnerServer.Tests; + +public class QueryStringParametersTests +{ + [Theory] + [InlineData(0, 1)] + [InlineData(-3, 1)] + [InlineData(2, 2)] + public void GivenAPageNumber_WhenItIsSet_ThenItNeverDropsBelowOne(int requested, int expected) + { + var parameters = new OwnerParameters { PageNumber = requested }; + + Assert.Equal(expected, parameters.PageNumber); + } + + [Theory] + [InlineData(0, 1)] + [InlineData(-10, 1)] + [InlineData(7, 7)] + [InlineData(500, 50)] + public void GivenAPageSize_WhenItIsSet_ThenItIsClampedBetweenOneAndFifty(int requested, int expected) + { + var parameters = new OwnerParameters { PageSize = requested }; + + Assert.Equal(expected, parameters.PageSize); + } + + [Fact] + public void GivenNoQueryString_WhenParametersAreCreated_ThenTheDefaultsAreOneAndTen() + { + var parameters = new OwnerParameters(); + + Assert.Equal(1, parameters.PageNumber); + Assert.Equal(10, parameters.PageSize); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/AccountOwnerServer.Tests/SqlServerFactAttribute.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/AccountOwnerServer.Tests/SqlServerFactAttribute.cs new file mode 100644 index 000000000..6ae7fafae --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/AccountOwnerServer.Tests/SqlServerFactAttribute.cs @@ -0,0 +1,20 @@ +using System; +using Xunit; + +namespace AccountOwnerServer.Tests; + +/// +/// A fact that runs only when SQL Server integration tests are switched on with +/// CODEMAZE_SQLSERVER_TESTS=1. The tests need a working Docker daemon: Testcontainers +/// starts mcr.microsoft.com/mssql/server for them. +/// +public sealed class SqlServerFactAttribute : FactAttribute +{ + public SqlServerFactAttribute() + { + if (Environment.GetEnvironmentVariable("CODEMAZE_SQLSERVER_TESTS") != "1") + { + Skip = "Set CODEMAZE_SQLSERVER_TESTS=1 (and have Docker running) to run the SQL Server tests."; + } + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/AccountOwnerServer.Tests/SqlServerSample.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/AccountOwnerServer.Tests/SqlServerSample.cs new file mode 100644 index 000000000..e557d6730 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/AccountOwnerServer.Tests/SqlServerSample.cs @@ -0,0 +1,73 @@ +using Entities; +using Entities.Models; +using Microsoft.EntityFrameworkCore; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Testcontainers.MsSql; + +namespace AccountOwnerServer.Tests; + +/// +/// A throwaway SQL Server container seeded with the five owners and eight accounts the +/// articles use. Each test owns its own container, so nothing is created when the tests +/// are skipped. +/// +public sealed class SqlServerSample : IAsyncDisposable +{ + private readonly MsSqlContainer _container; + + private SqlServerSample(MsSqlContainer container) + { + _container = container; + } + + public static async Task StartAsync() + { + var container = new MsSqlBuilder("mcr.microsoft.com/mssql/server:2022-latest").Build(); + await container.StartAsync(); + + var sample = new SqlServerSample(container); + + await using var context = sample.CreateContext(); + await context.Database.EnsureCreatedAsync(); + + context.Owners.AddRange(Owners()); + context.Accounts.AddRange(Accounts()); + await context.SaveChangesAsync(); + + return sample; + } + + public RepositoryContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseSqlServer(_container.GetConnectionString()) + .Options; + + return new RepositoryContext(options); + } + + public async ValueTask DisposeAsync() => await _container.DisposeAsync(); + + public static List Owners() => + [ + new() { Id = Guid.Parse("24fd81f8-d58a-4bcc-9f35-dc6cd5641906"), Name = "John Keen", DateOfBirth = new DateTime(1980, 12, 5), Address = "61 Wellfield Road" }, + new() { Id = Guid.Parse("261e1685-cf26-494c-b17c-3546e65f5620"), Name = "Anna Bosh", DateOfBirth = new DateTime(1974, 11, 14), Address = "27 Colored Row" }, + new() { Id = Guid.Parse("66774006-2371-4d5b-8518-2177bcf3f73e"), Name = "Nick Somion", DateOfBirth = new DateTime(1998, 12, 15), Address = "North sunny address 102" }, + new() { Id = Guid.Parse("a3c1880c-674c-4d18-8f91-5d3608a2c937"), Name = "Sam Query", DateOfBirth = new DateTime(1990, 4, 22), Address = "91 Western Roads" }, + new() { Id = Guid.Parse("f98e4d74-0f68-4aac-89fd-047f1aaca6b6"), Name = "Martin Miller", DateOfBirth = new DateTime(1983, 5, 21), Address = "3 Edgar Buildings" } + ]; + + public static List Accounts() => + [ + new() { Id = Guid.Parse("03e91478-5608-4132-a753-d494dafce00b"), DateCreated = new DateTime(2003, 12, 15), AccountType = "Domestic", OwnerId = Guid.Parse("f98e4d74-0f68-4aac-89fd-047f1aaca6b6") }, + new() { Id = Guid.Parse("356a5a9b-64bf-4de0-bc84-5395a1fdc9c4"), DateCreated = new DateTime(1996, 2, 15), AccountType = "Domestic", OwnerId = Guid.Parse("261e1685-cf26-494c-b17c-3546e65f5620") }, + new() { Id = Guid.Parse("371b93f2-f8c5-4a32-894a-fc672741aa5b"), DateCreated = new DateTime(1999, 5, 4), AccountType = "Domestic", OwnerId = Guid.Parse("24fd81f8-d58a-4bcc-9f35-dc6cd5641906") }, + new() { Id = Guid.Parse("670775db-ecc0-4b90-a9ab-37cd0d8e2801"), DateCreated = new DateTime(1999, 12, 21), AccountType = "Savings", OwnerId = Guid.Parse("24fd81f8-d58a-4bcc-9f35-dc6cd5641906") }, + new() { Id = Guid.Parse("a3fbad0b-7f48-4feb-8ac0-6d3bbc997bfc"), DateCreated = new DateTime(2010, 5, 28), AccountType = "Domestic", OwnerId = Guid.Parse("a3c1880c-674c-4d18-8f91-5d3608a2c937") }, + new() { Id = Guid.Parse("aa15f658-04bb-4f73-82af-82db49d0fbef"), DateCreated = new DateTime(1999, 5, 12), AccountType = "Foreign", OwnerId = Guid.Parse("24fd81f8-d58a-4bcc-9f35-dc6cd5641906") }, + new() { Id = Guid.Parse("c6066eb0-53ca-43e1-97aa-3c2169eec659"), DateCreated = new DateTime(1996, 2, 16), AccountType = "Foreign", OwnerId = Guid.Parse("261e1685-cf26-494c-b17c-3546e65f5620") }, + new() { Id = Guid.Parse("eccadf79-85fe-402f-893c-32d3f03ed9b1"), DateCreated = new DateTime(2010, 6, 20), AccountType = "Foreign", OwnerId = Guid.Parse("a3c1880c-674c-4d18-8f91-5d3608a2c937") } + ]; +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/AccountOwnerServer.sln b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/AccountOwnerServer.sln new file mode 100644 index 000000000..e5435d399 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/AccountOwnerServer.sln @@ -0,0 +1,104 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AccountOwnerServer", "AccountOwnerServer\AccountOwnerServer.csproj", "{2C22A5C3-F1E4-44FC-AFCE-94F6CAA1D9ED}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LoggerService", "LoggerService\LoggerService.csproj", "{F3F84929-6AB3-4F0B-9502-5E6AB901E12D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contracts", "Contracts\Contracts.csproj", "{39CF8E58-AF4B-46AA-95CC-F7C6F86F6C64}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Entities", "Entities\Entities.csproj", "{7C310E13-928C-412C-BA4B-05B1B316415D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Repository", "Repository\Repository.csproj", "{E6B3A5AD-01D0-428B-8C4A-4719526CA5D1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AccountOwnerServer.Tests", "AccountOwnerServer.Tests\AccountOwnerServer.Tests.csproj", "{347852CE-E62B-4FCF-9334-C82D67C51365}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {2C22A5C3-F1E4-44FC-AFCE-94F6CAA1D9ED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2C22A5C3-F1E4-44FC-AFCE-94F6CAA1D9ED}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2C22A5C3-F1E4-44FC-AFCE-94F6CAA1D9ED}.Debug|x64.ActiveCfg = Debug|Any CPU + {2C22A5C3-F1E4-44FC-AFCE-94F6CAA1D9ED}.Debug|x64.Build.0 = Debug|Any CPU + {2C22A5C3-F1E4-44FC-AFCE-94F6CAA1D9ED}.Debug|x86.ActiveCfg = Debug|Any CPU + {2C22A5C3-F1E4-44FC-AFCE-94F6CAA1D9ED}.Debug|x86.Build.0 = Debug|Any CPU + {2C22A5C3-F1E4-44FC-AFCE-94F6CAA1D9ED}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2C22A5C3-F1E4-44FC-AFCE-94F6CAA1D9ED}.Release|Any CPU.Build.0 = Release|Any CPU + {2C22A5C3-F1E4-44FC-AFCE-94F6CAA1D9ED}.Release|x64.ActiveCfg = Release|Any CPU + {2C22A5C3-F1E4-44FC-AFCE-94F6CAA1D9ED}.Release|x64.Build.0 = Release|Any CPU + {2C22A5C3-F1E4-44FC-AFCE-94F6CAA1D9ED}.Release|x86.ActiveCfg = Release|Any CPU + {2C22A5C3-F1E4-44FC-AFCE-94F6CAA1D9ED}.Release|x86.Build.0 = Release|Any CPU + {F3F84929-6AB3-4F0B-9502-5E6AB901E12D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F3F84929-6AB3-4F0B-9502-5E6AB901E12D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F3F84929-6AB3-4F0B-9502-5E6AB901E12D}.Debug|x64.ActiveCfg = Debug|Any CPU + {F3F84929-6AB3-4F0B-9502-5E6AB901E12D}.Debug|x64.Build.0 = Debug|Any CPU + {F3F84929-6AB3-4F0B-9502-5E6AB901E12D}.Debug|x86.ActiveCfg = Debug|Any CPU + {F3F84929-6AB3-4F0B-9502-5E6AB901E12D}.Debug|x86.Build.0 = Debug|Any CPU + {F3F84929-6AB3-4F0B-9502-5E6AB901E12D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F3F84929-6AB3-4F0B-9502-5E6AB901E12D}.Release|Any CPU.Build.0 = Release|Any CPU + {F3F84929-6AB3-4F0B-9502-5E6AB901E12D}.Release|x64.ActiveCfg = Release|Any CPU + {F3F84929-6AB3-4F0B-9502-5E6AB901E12D}.Release|x64.Build.0 = Release|Any CPU + {F3F84929-6AB3-4F0B-9502-5E6AB901E12D}.Release|x86.ActiveCfg = Release|Any CPU + {F3F84929-6AB3-4F0B-9502-5E6AB901E12D}.Release|x86.Build.0 = Release|Any CPU + {39CF8E58-AF4B-46AA-95CC-F7C6F86F6C64}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {39CF8E58-AF4B-46AA-95CC-F7C6F86F6C64}.Debug|Any CPU.Build.0 = Debug|Any CPU + {39CF8E58-AF4B-46AA-95CC-F7C6F86F6C64}.Debug|x64.ActiveCfg = Debug|Any CPU + {39CF8E58-AF4B-46AA-95CC-F7C6F86F6C64}.Debug|x64.Build.0 = Debug|Any CPU + {39CF8E58-AF4B-46AA-95CC-F7C6F86F6C64}.Debug|x86.ActiveCfg = Debug|Any CPU + {39CF8E58-AF4B-46AA-95CC-F7C6F86F6C64}.Debug|x86.Build.0 = Debug|Any CPU + {39CF8E58-AF4B-46AA-95CC-F7C6F86F6C64}.Release|Any CPU.ActiveCfg = Release|Any CPU + {39CF8E58-AF4B-46AA-95CC-F7C6F86F6C64}.Release|Any CPU.Build.0 = Release|Any CPU + {39CF8E58-AF4B-46AA-95CC-F7C6F86F6C64}.Release|x64.ActiveCfg = Release|Any CPU + {39CF8E58-AF4B-46AA-95CC-F7C6F86F6C64}.Release|x64.Build.0 = Release|Any CPU + {39CF8E58-AF4B-46AA-95CC-F7C6F86F6C64}.Release|x86.ActiveCfg = Release|Any CPU + {39CF8E58-AF4B-46AA-95CC-F7C6F86F6C64}.Release|x86.Build.0 = Release|Any CPU + {7C310E13-928C-412C-BA4B-05B1B316415D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7C310E13-928C-412C-BA4B-05B1B316415D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7C310E13-928C-412C-BA4B-05B1B316415D}.Debug|x64.ActiveCfg = Debug|Any CPU + {7C310E13-928C-412C-BA4B-05B1B316415D}.Debug|x64.Build.0 = Debug|Any CPU + {7C310E13-928C-412C-BA4B-05B1B316415D}.Debug|x86.ActiveCfg = Debug|Any CPU + {7C310E13-928C-412C-BA4B-05B1B316415D}.Debug|x86.Build.0 = Debug|Any CPU + {7C310E13-928C-412C-BA4B-05B1B316415D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7C310E13-928C-412C-BA4B-05B1B316415D}.Release|Any CPU.Build.0 = Release|Any CPU + {7C310E13-928C-412C-BA4B-05B1B316415D}.Release|x64.ActiveCfg = Release|Any CPU + {7C310E13-928C-412C-BA4B-05B1B316415D}.Release|x64.Build.0 = Release|Any CPU + {7C310E13-928C-412C-BA4B-05B1B316415D}.Release|x86.ActiveCfg = Release|Any CPU + {7C310E13-928C-412C-BA4B-05B1B316415D}.Release|x86.Build.0 = Release|Any CPU + {E6B3A5AD-01D0-428B-8C4A-4719526CA5D1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E6B3A5AD-01D0-428B-8C4A-4719526CA5D1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E6B3A5AD-01D0-428B-8C4A-4719526CA5D1}.Debug|x64.ActiveCfg = Debug|Any CPU + {E6B3A5AD-01D0-428B-8C4A-4719526CA5D1}.Debug|x64.Build.0 = Debug|Any CPU + {E6B3A5AD-01D0-428B-8C4A-4719526CA5D1}.Debug|x86.ActiveCfg = Debug|Any CPU + {E6B3A5AD-01D0-428B-8C4A-4719526CA5D1}.Debug|x86.Build.0 = Debug|Any CPU + {E6B3A5AD-01D0-428B-8C4A-4719526CA5D1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E6B3A5AD-01D0-428B-8C4A-4719526CA5D1}.Release|Any CPU.Build.0 = Release|Any CPU + {E6B3A5AD-01D0-428B-8C4A-4719526CA5D1}.Release|x64.ActiveCfg = Release|Any CPU + {E6B3A5AD-01D0-428B-8C4A-4719526CA5D1}.Release|x64.Build.0 = Release|Any CPU + {E6B3A5AD-01D0-428B-8C4A-4719526CA5D1}.Release|x86.ActiveCfg = Release|Any CPU + {E6B3A5AD-01D0-428B-8C4A-4719526CA5D1}.Release|x86.Build.0 = Release|Any CPU + {347852CE-E62B-4FCF-9334-C82D67C51365}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {347852CE-E62B-4FCF-9334-C82D67C51365}.Debug|Any CPU.Build.0 = Debug|Any CPU + {347852CE-E62B-4FCF-9334-C82D67C51365}.Debug|x64.ActiveCfg = Debug|Any CPU + {347852CE-E62B-4FCF-9334-C82D67C51365}.Debug|x64.Build.0 = Debug|Any CPU + {347852CE-E62B-4FCF-9334-C82D67C51365}.Debug|x86.ActiveCfg = Debug|Any CPU + {347852CE-E62B-4FCF-9334-C82D67C51365}.Debug|x86.Build.0 = Debug|Any CPU + {347852CE-E62B-4FCF-9334-C82D67C51365}.Release|Any CPU.ActiveCfg = Release|Any CPU + {347852CE-E62B-4FCF-9334-C82D67C51365}.Release|Any CPU.Build.0 = Release|Any CPU + {347852CE-E62B-4FCF-9334-C82D67C51365}.Release|x64.ActiveCfg = Release|Any CPU + {347852CE-E62B-4FCF-9334-C82D67C51365}.Release|x64.Build.0 = Release|Any CPU + {347852CE-E62B-4FCF-9334-C82D67C51365}.Release|x86.ActiveCfg = Release|Any CPU + {347852CE-E62B-4FCF-9334-C82D67C51365}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/AccountOwnerServer/AccountOwnerServer.csproj b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/AccountOwnerServer/AccountOwnerServer.csproj new file mode 100644 index 000000000..8d9f2e4a6 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/AccountOwnerServer/AccountOwnerServer.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + enable + + + + + + + + + + + + diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/AccountOwnerServer/Controllers/AccountController.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/AccountOwnerServer/Controllers/AccountController.cs new file mode 100644 index 000000000..a3c8e8dc7 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/AccountOwnerServer/Controllers/AccountController.cs @@ -0,0 +1,62 @@ +using Contracts; +using Entities.Extensions; +using Entities.Models; +using Microsoft.AspNetCore.Mvc; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using System; + +namespace AccountOwnerServer.Controllers; + +[Route("api/owners/{ownerId}/accounts")] +[ApiController] +public class AccountController : ControllerBase +{ + private readonly ILoggerManager _logger; + private readonly IRepositoryWrapper _repository; + + public AccountController(ILoggerManager logger, + IRepositoryWrapper repository) + { + _logger = logger; + _repository = repository; + } + + [HttpGet] + public async Task>> GetAccountsForOwner(Guid ownerId, [FromQuery] AccountParameters parameters) + { + var accounts = await _repository.Account.GetAccountsByOwner(ownerId, parameters); + + var metadata = new + { + accounts.TotalCount, + accounts.PageSize, + accounts.CurrentPage, + accounts.TotalPages, + accounts.HasNext, + accounts.HasPrevious + }; + + Response.Headers["X-Pagination"] = JsonSerializer.Serialize(metadata); + + _logger.LogInfo($"Returned {accounts.TotalCount} accounts from database."); + + return Ok(accounts); + } + + [HttpGet("{id}")] + public IActionResult GetAccountForOwner(Guid ownerId, Guid id) + { + var account = _repository.Account.GetAccountByOwner(ownerId, id); + + if (account.IsObjectNull()) + { + _logger.LogError($"Account with id: {id}, hasn't been found in db."); + return NotFound(); + } + + return Ok(account); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/AccountOwnerServer/Controllers/OwnerController.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/AccountOwnerServer/Controllers/OwnerController.cs new file mode 100644 index 000000000..ef0d08dea --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/AccountOwnerServer/Controllers/OwnerController.cs @@ -0,0 +1,139 @@ +using Contracts; +using Entities.Extensions; +using Entities.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using System; + +namespace AccountOwnerServer.Controllers; + +[Route("api/owners")] +[ApiController] +public class OwnerController : ControllerBase +{ + private readonly ILoggerManager _logger; + private readonly IRepositoryWrapper _repository; + + public OwnerController(ILoggerManager logger, + IRepositoryWrapper repository) + { + _logger = logger; + _repository = repository; + } + + [HttpGet] + public async Task>> GetOwners([FromQuery] OwnerParameters ownerParameters) + { + if (!ownerParameters.ValidYearRange) + { + return Problem( + detail: "maxYearOfBirth must be greater than or equal to minYearOfBirth.", + statusCode: StatusCodes.Status400BadRequest); + } + + var owners = await _repository.Owner.GetOwners(ownerParameters); + + var metadata = new + { + owners.TotalCount, + owners.PageSize, + owners.CurrentPage, + owners.TotalPages, + owners.HasNext, + owners.HasPrevious + }; + + Response.Headers["X-Pagination"] = JsonSerializer.Serialize(metadata); + + _logger.LogInfo($"Returned {owners.TotalCount} owners from database."); + + return Ok(owners); + } + + [HttpGet("{id}", Name = "OwnerById")] + public IActionResult GetOwnerById(Guid id) + { + var owner = _repository.Owner.GetOwnerById(id); + + if (owner.IsEmptyObject()) + { + _logger.LogError($"Owner with id: {id}, hasn't been found in db."); + return NotFound(); + } + + _logger.LogInfo($"Returned owner with id: {id}"); + + return Ok(owner); + } + + [HttpPost] + public IActionResult CreateOwner([FromBody] Owner owner) + { + if (owner.IsObjectNull()) + { + _logger.LogError("Owner object sent from client is null."); + return BadRequest("Owner object is null"); + } + + if (!ModelState.IsValid) + { + _logger.LogError("Invalid owner object sent from client."); + return BadRequest("Invalid model object"); + } + + _repository.Owner.CreateOwner(owner); + _repository.Save(); + + return CreatedAtRoute("OwnerById", new { id = owner.Id }, owner); + } + + [HttpPut("{id}")] + public IActionResult UpdateOwner(Guid id, [FromBody] Owner owner) + { + if (owner.IsObjectNull()) + { + _logger.LogError("Owner object sent from client is null."); + return BadRequest("Owner object is null"); + } + + if (!ModelState.IsValid) + { + _logger.LogError("Invalid owner object sent from client."); + return BadRequest("Invalid model object"); + } + + var dbOwner = _repository.Owner.GetOwnerById(id); + + if (dbOwner.IsEmptyObject()) + { + _logger.LogError($"Owner with id: {id}, hasn't been found in db."); + return NotFound(); + } + + _repository.Owner.UpdateOwner(dbOwner, owner); + _repository.Save(); + + return NoContent(); + } + + [HttpDelete("{id}")] + public IActionResult DeleteOwner(Guid id) + { + var owner = _repository.Owner.GetOwnerById(id); + + if (owner.IsEmptyObject()) + { + _logger.LogError($"Owner with id: {id}, hasn't been found in db."); + return NotFound(); + } + + _repository.Owner.DeleteOwner(owner); + _repository.Save(); + + return NoContent(); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/AccountOwnerServer/Extensions/ServiceExtensions.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/AccountOwnerServer/Extensions/ServiceExtensions.cs new file mode 100644 index 000000000..3acc356d1 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/AccountOwnerServer/Extensions/ServiceExtensions.cs @@ -0,0 +1,35 @@ +using Contracts; +using Entities; +using LoggerService; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Repository; + +namespace AccountOwnerServer.Extensions; + +public static class ServiceExtensions +{ + public static void ConfigureCors(this IServiceCollection services) => + services.AddCors(options => + options.AddPolicy("CorsPolicy", + builder => builder.WithOrigins("http://localhost:5000", "https://localhost:5001") + .AllowAnyMethod() + .AllowAnyHeader() + .AllowCredentials() + .WithExposedHeaders("X-Pagination"))); + + public static void ConfigureLoggerService(this IServiceCollection services) => + services.AddSingleton(); + + public static void ConfigureSqlContext(this IServiceCollection services, IConfiguration config) + { + var connectionString = config["sqlconnection:connectionString"]; + services.AddDbContext(o => o.UseSqlServer(connectionString)); + } + + public static void ConfigureRepositoryWrapper(this IServiceCollection services) + { + services.AddScoped(); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/AccountOwnerServer/Program.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/AccountOwnerServer/Program.cs new file mode 100644 index 000000000..b45a72e8c --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/AccountOwnerServer/Program.cs @@ -0,0 +1,60 @@ +using AccountOwnerServer.Extensions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using NLog; +using System.Net; + +LogManager.Setup().LoadConfigurationFromFile("nlog.config"); + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.ConfigureCors(); +builder.Services.ConfigureLoggerService(); +builder.Services.ConfigureSqlContext(builder.Configuration); +builder.Services.ConfigureRepositoryWrapper(); + +builder.Services.AddControllers(); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) + app.UseDeveloperExceptionPage(); +else + app.UseHsts(); + +app.UseExceptionHandler(appError => +{ + appError.Run(async context => + { + context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; + context.Response.ContentType = "application/json"; + + var contextFeature = context.Features.Get(); + + if (contextFeature is not null) + { + await context.Response.WriteAsJsonAsync(new + { + context.Response.StatusCode, + Message = "Internal Server Error." + }); + } + }); +}); + +app.UseHttpsRedirection(); + +app.UseCors("CorsPolicy"); + +app.UseForwardedHeaders(new ForwardedHeadersOptions +{ + ForwardedHeaders = ForwardedHeaders.All +}); + +app.MapControllers(); + +app.Run(); diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/AccountOwnerServer/Properties/launchSettings.json b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/AccountOwnerServer/Properties/launchSettings.json new file mode 100644 index 000000000..a82b9223a --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/AccountOwnerServer/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "AccountOwnerServer": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "api/owners", + "applicationUrl": "https://localhost:5001;http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/AccountOwnerServer/appsettings.Development.json b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/AccountOwnerServer/appsettings.Development.json new file mode 100644 index 000000000..e203e9407 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/AccountOwnerServer/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + } + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/AccountOwnerServer/appsettings.json b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/AccountOwnerServer/appsettings.json new file mode 100644 index 000000000..0096cb3d4 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/AccountOwnerServer/appsettings.json @@ -0,0 +1,11 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning" + } + }, + "sqlconnection": { + "connectionString": "Server=(localdb)\\mssqllocaldb;Database=AccountOwner;Trusted_Connection=True;TrustServerCertificate=True;" + }, + "AllowedHosts": "*" +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/AccountOwnerServer/nlog.config b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/AccountOwnerServer/nlog.config new file mode 100644 index 000000000..52f675aa6 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/AccountOwnerServer/nlog.config @@ -0,0 +1,17 @@ + + + + + + + + + + + diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Contracts/Contracts.csproj b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Contracts/Contracts.csproj new file mode 100644 index 000000000..b201bbf27 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Contracts/Contracts.csproj @@ -0,0 +1,12 @@ + + + + net10.0 + enable + + + + + + + diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Contracts/IAccountRepository.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Contracts/IAccountRepository.cs new file mode 100644 index 000000000..9a0730ab7 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Contracts/IAccountRepository.cs @@ -0,0 +1,12 @@ +using Entities.Helpers; +using Entities.Models; +using System; +using System.Threading.Tasks; + +namespace Contracts; + +public interface IAccountRepository : IRepositoryBase +{ + Task> GetAccountsByOwner(Guid ownerId, AccountParameters parameters); + Account? GetAccountByOwner(Guid ownerId, Guid id); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Contracts/ILoggerManager.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Contracts/ILoggerManager.cs new file mode 100644 index 000000000..42d30745d --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Contracts/ILoggerManager.cs @@ -0,0 +1,9 @@ +namespace Contracts; + +public interface ILoggerManager +{ + void LogInfo(string message); + void LogWarn(string message); + void LogDebug(string message); + void LogError(string message); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Contracts/IOwnerRepository.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Contracts/IOwnerRepository.cs new file mode 100644 index 000000000..137669404 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Contracts/IOwnerRepository.cs @@ -0,0 +1,15 @@ +using Entities.Helpers; +using Entities.Models; +using System; +using System.Threading.Tasks; + +namespace Contracts; + +public interface IOwnerRepository : IRepositoryBase +{ + Task> GetOwners(OwnerParameters ownerParameters); + Owner GetOwnerById(Guid ownerId); + void CreateOwner(Owner owner); + void UpdateOwner(Owner dbOwner, Owner owner); + void DeleteOwner(Owner owner); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Contracts/IRepositoryBase.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Contracts/IRepositoryBase.cs new file mode 100644 index 000000000..51283872d --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Contracts/IRepositoryBase.cs @@ -0,0 +1,14 @@ +using System; +using System.Linq; +using System.Linq.Expressions; + +namespace Contracts; + +public interface IRepositoryBase +{ + IQueryable FindAll(); + IQueryable FindByCondition(Expression> expression); + void Create(T entity); + void Update(T entity); + void Delete(T entity); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Contracts/IRepositoryWrapper.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Contracts/IRepositoryWrapper.cs new file mode 100644 index 000000000..b7dd600fe --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Contracts/IRepositoryWrapper.cs @@ -0,0 +1,8 @@ +namespace Contracts; + +public interface IRepositoryWrapper +{ + IOwnerRepository Owner { get; } + IAccountRepository Account { get; } + void Save(); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Entities/Entities.csproj b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Entities/Entities.csproj new file mode 100644 index 000000000..ea034f6d3 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Entities/Entities.csproj @@ -0,0 +1,12 @@ + + + + net10.0 + enable + + + + + + + diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Entities/Enumerations/AccountType.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Entities/Enumerations/AccountType.cs new file mode 100644 index 000000000..b84e0ad48 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Entities/Enumerations/AccountType.cs @@ -0,0 +1,8 @@ +namespace Entities.Enumerations; + +public enum AccountType +{ + Domestic, + Savings, + Foreign +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Entities/Extensions/IEntityExtensions.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Entities/Extensions/IEntityExtensions.cs new file mode 100644 index 000000000..243eafc70 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Entities/Extensions/IEntityExtensions.cs @@ -0,0 +1,10 @@ +using System; + +namespace Entities.Extensions; + +public static class IEntityExtensions +{ + public static bool IsObjectNull(this IEntity? entity) => entity is null; + + public static bool IsEmptyObject(this IEntity entity) => entity.Id.Equals(Guid.Empty); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Entities/Extensions/OwnerExtensions.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Entities/Extensions/OwnerExtensions.cs new file mode 100644 index 000000000..ba2768266 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Entities/Extensions/OwnerExtensions.cs @@ -0,0 +1,13 @@ +using Entities.Models; + +namespace Entities.Extensions; + +public static class OwnerExtensions +{ + public static void Map(this Owner dbOwner, Owner owner) + { + dbOwner.Name = owner.Name; + dbOwner.Address = owner.Address; + dbOwner.DateOfBirth = owner.DateOfBirth; + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Entities/Helpers/PagedList.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Entities/Helpers/PagedList.cs new file mode 100644 index 000000000..a81287b98 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Entities/Helpers/PagedList.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; + +namespace Entities.Helpers; + +public class PagedList : List +{ + public int CurrentPage { get; private set; } + public int TotalPages { get; private set; } + public int PageSize { get; private set; } + public int TotalCount { get; private set; } + + public bool HasPrevious => CurrentPage > 1; + public bool HasNext => CurrentPage < TotalPages; + + public PagedList() + { + } + + public PagedList(List items, int count, int pageNumber, int pageSize) + { + TotalCount = count; + PageSize = pageSize; + CurrentPage = pageNumber; + TotalPages = (int)Math.Ceiling(count / (double)pageSize); + + AddRange(items); + } + + public static async Task> ToPagedListAsync(IQueryable source, int pageNumber, int pageSize) + { + var count = await source.CountAsync(); + var items = await source.Skip((pageNumber - 1) * pageSize).Take(pageSize).ToListAsync(); + + return new PagedList(items, count, pageNumber, pageSize); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Entities/IEntity.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Entities/IEntity.cs new file mode 100644 index 000000000..825a8525b --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Entities/IEntity.cs @@ -0,0 +1,8 @@ +using System; + +namespace Entities; + +public interface IEntity +{ + Guid Id { get; set; } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Entities/Models/Account.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Entities/Models/Account.cs new file mode 100644 index 000000000..7a5590d9f --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Entities/Models/Account.cs @@ -0,0 +1,22 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Entities.Models; + +[Table("account")] +public class Account : IEntity +{ + [Key] + [Column("AccountId")] + public Guid Id { get; set; } + + [Required(ErrorMessage = "Date created is required")] + public DateTime DateCreated { get; set; } + + [Required(ErrorMessage = "Account type is required")] + public string AccountType { get; set; } = string.Empty; + + [Required(ErrorMessage = "Owner Id is required")] + public Guid OwnerId { get; set; } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Entities/Models/AccountParameters.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Entities/Models/AccountParameters.cs new file mode 100644 index 000000000..8da0393ae --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Entities/Models/AccountParameters.cs @@ -0,0 +1,5 @@ +namespace Entities.Models; + +public class AccountParameters : QueryStringParameters +{ +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Entities/Models/Owner.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Entities/Models/Owner.cs new file mode 100644 index 000000000..05b422d00 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Entities/Models/Owner.cs @@ -0,0 +1,24 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Entities.Models; + +[Table("owner")] +public class Owner : IEntity +{ + [Key] + [Column("OwnerId")] + public Guid Id { get; set; } + + [Required(ErrorMessage = "Name is required")] + [StringLength(60, ErrorMessage = "Name can't be longer than 60 characters")] + public string Name { get; set; } = string.Empty; + + [Required(ErrorMessage = "Date of birth is required")] + public DateTime DateOfBirth { get; set; } + + [Required(ErrorMessage = "Address is required")] + [StringLength(100, ErrorMessage = "Address can't be longer than 100 characters")] + public string Address { get; set; } = string.Empty; +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Entities/Models/OwnerParameters.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Entities/Models/OwnerParameters.cs new file mode 100644 index 000000000..e62cfbb09 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Entities/Models/OwnerParameters.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace Entities.Models; + +public class OwnerParameters : QueryStringParameters +{ + [Range(1900, 2100)] + public int? MinYearOfBirth { get; set; } + + [Range(1900, 2100)] + public int? MaxYearOfBirth { get; set; } + + public bool ValidYearRange => + MinYearOfBirth is null || MaxYearOfBirth is null || MaxYearOfBirth >= MinYearOfBirth; +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Entities/Models/QueryStringParameters.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Entities/Models/QueryStringParameters.cs new file mode 100644 index 000000000..b017779a9 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Entities/Models/QueryStringParameters.cs @@ -0,0 +1,23 @@ +using System; + +namespace Entities.Models; + +public abstract class QueryStringParameters +{ + const int MaxPageSize = 50; + + private int _pageNumber = 1; + private int _pageSize = 10; + + public int PageNumber + { + get => _pageNumber; + set => _pageNumber = Math.Max(value, 1); + } + + public int PageSize + { + get => _pageSize; + set => _pageSize = Math.Clamp(value, 1, MaxPageSize); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Entities/RepositoryContext.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Entities/RepositoryContext.cs new file mode 100644 index 000000000..1857e8e5f --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Entities/RepositoryContext.cs @@ -0,0 +1,15 @@ +using Entities.Models; +using Microsoft.EntityFrameworkCore; + +namespace Entities; + +public class RepositoryContext : DbContext +{ + public RepositoryContext(DbContextOptions options) + : base(options) + { + } + + public DbSet Owners { get; set; } = null!; + public DbSet Accounts { get; set; } = null!; +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/LoggerService/LoggerManager.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/LoggerService/LoggerManager.cs new file mode 100644 index 000000000..dd71d82d6 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/LoggerService/LoggerManager.cs @@ -0,0 +1,17 @@ +using Contracts; +using NLog; + +namespace LoggerService; + +public class LoggerManager : ILoggerManager +{ + private static readonly ILogger Logger = LogManager.GetCurrentClassLogger(); + + public void LogDebug(string message) => Logger.Debug(message); + + public void LogError(string message) => Logger.Error(message); + + public void LogInfo(string message) => Logger.Info(message); + + public void LogWarn(string message) => Logger.Warn(message); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/LoggerService/LoggerService.csproj b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/LoggerService/LoggerService.csproj new file mode 100644 index 000000000..8adf826f0 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/LoggerService/LoggerService.csproj @@ -0,0 +1,16 @@ + + + + net10.0 + enable + + + + + + + + + + + diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Repository/AccountRepository.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Repository/AccountRepository.cs new file mode 100644 index 000000000..888e94bed --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Repository/AccountRepository.cs @@ -0,0 +1,31 @@ +using Contracts; +using Entities; +using Entities.Helpers; +using Entities.Models; +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace Repository; + +public class AccountRepository : RepositoryBase, IAccountRepository +{ + public AccountRepository(RepositoryContext repositoryContext) + : base(repositoryContext) + { + } + + public Task> GetAccountsByOwner(Guid ownerId, AccountParameters parameters) + { + var accounts = FindByCondition(a => a.OwnerId.Equals(ownerId)); + + var sortedAccounts = accounts.OrderBy(a => a.DateCreated); + + return PagedList.ToPagedListAsync(sortedAccounts, + parameters.PageNumber, + parameters.PageSize); + } + + public Account? GetAccountByOwner(Guid ownerId, Guid id) => + FindByCondition(a => a.OwnerId.Equals(ownerId) && a.Id.Equals(id)).SingleOrDefault(); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Repository/OwnerRepository.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Repository/OwnerRepository.cs new file mode 100644 index 000000000..0483641b8 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Repository/OwnerRepository.cs @@ -0,0 +1,48 @@ +using Contracts; +using Entities; +using Entities.Extensions; +using Entities.Helpers; +using Entities.Models; +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace Repository; + +public class OwnerRepository : RepositoryBase, IOwnerRepository +{ + public OwnerRepository(RepositoryContext repositoryContext) + : base(repositoryContext) + { + } + + public Task> GetOwners(OwnerParameters ownerParameters) + { + var owners = FindAll(); + + if (ownerParameters.MinYearOfBirth is { } minYear) + owners = owners.Where(o => o.DateOfBirth >= new DateTime(minYear, 1, 1)); + + if (ownerParameters.MaxYearOfBirth is { } maxYear) + owners = owners.Where(o => o.DateOfBirth < new DateTime(maxYear + 1, 1, 1)); + + var sortedOwners = owners.OrderBy(o => o.Name); + + return PagedList.ToPagedListAsync(sortedOwners, + ownerParameters.PageNumber, + ownerParameters.PageSize); + } + + public Owner GetOwnerById(Guid ownerId) => + FindByCondition(owner => owner.Id.Equals(ownerId)).SingleOrDefault() ?? new Owner(); + + public void CreateOwner(Owner owner) => Create(owner); + + public void UpdateOwner(Owner dbOwner, Owner owner) + { + dbOwner.Map(owner); + Update(dbOwner); + } + + public void DeleteOwner(Owner owner) => Delete(owner); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Repository/Repository.csproj b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Repository/Repository.csproj new file mode 100644 index 000000000..61a67779d --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Repository/Repository.csproj @@ -0,0 +1,16 @@ + + + + net10.0 + enable + + + + + + + + + + + diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Repository/RepositoryBase.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Repository/RepositoryBase.cs new file mode 100644 index 000000000..51053f279 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Repository/RepositoryBase.cs @@ -0,0 +1,33 @@ +using Contracts; +using Entities; +using Microsoft.EntityFrameworkCore; +using System; +using System.Linq; +using System.Linq.Expressions; + +namespace Repository; + +public abstract class RepositoryBase : IRepositoryBase where T : class +{ + protected RepositoryContext RepositoryContext { get; set; } + + protected RepositoryBase(RepositoryContext repositoryContext) + { + RepositoryContext = repositoryContext; + } + + public IQueryable FindAll() => + RepositoryContext.Set() + .AsNoTracking(); + + public IQueryable FindByCondition(Expression> expression) => + RepositoryContext.Set() + .Where(expression) + .AsNoTracking(); + + public void Create(T entity) => RepositoryContext.Set().Add(entity); + + public void Update(T entity) => RepositoryContext.Set().Update(entity); + + public void Delete(T entity) => RepositoryContext.Set().Remove(entity); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Repository/RepositoryWrapper.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Repository/RepositoryWrapper.cs new file mode 100644 index 000000000..653c4a87c --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/Repository/RepositoryWrapper.cs @@ -0,0 +1,22 @@ +using Contracts; +using Entities; + +namespace Repository; + +public class RepositoryWrapper : IRepositoryWrapper +{ + private readonly RepositoryContext _repoContext; + private IOwnerRepository? _owner; + private IAccountRepository? _account; + + public RepositoryWrapper(RepositoryContext repositoryContext) + { + _repoContext = repositoryContext; + } + + public IOwnerRepository Owner => _owner ??= new OwnerRepository(_repoContext); + + public IAccountRepository Account => _account ??= new AccountRepository(_repoContext); + + public void Save() => _repoContext.SaveChanges(); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/_SqlServer_Init_Script/init.sql b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/_SqlServer_Init_Script/init.sql new file mode 100644 index 000000000..f8232089c --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Filtering/_SqlServer_Init_Script/init.sql @@ -0,0 +1,44 @@ +-- SQL Server schema and seed data for the Advanced REST Concepts sample. +-- Run it against a database of your choice, then point sqlconnection:connectionString at it. +-- On Windows the default connection string uses LocalDB. Elsewhere, one line gets you a server: +-- docker run -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=Str0ng!Passw0rd" -p 1433:1433 -d mcr.microsoft.com/mssql/server:2022-latest + +IF OBJECT_ID('dbo.account', 'U') IS NOT NULL DROP TABLE dbo.account; +IF OBJECT_ID('dbo.owner', 'U') IS NOT NULL DROP TABLE dbo.owner; + +CREATE TABLE dbo.owner +( + OwnerId uniqueidentifier NOT NULL PRIMARY KEY, + Name nvarchar(60) NOT NULL, + DateOfBirth datetime2 NOT NULL, + Address nvarchar(100) NOT NULL +); + +CREATE TABLE dbo.account +( + AccountId uniqueidentifier NOT NULL PRIMARY KEY, + DateCreated datetime2 NOT NULL, + AccountType nvarchar(45) NOT NULL, + OwnerId uniqueidentifier NOT NULL + CONSTRAINT fk_account_owner REFERENCES dbo.owner (OwnerId) ON UPDATE CASCADE +); + +CREATE INDEX ix_owner_name ON dbo.owner (Name); +CREATE INDEX ix_owner_dateofbirth ON dbo.owner (DateOfBirth); + +INSERT INTO dbo.owner (OwnerId, Name, DateOfBirth, Address) VALUES + ('24fd81f8-d58a-4bcc-9f35-dc6cd5641906', 'John Keen', '1980-12-05', '61 Wellfield Road'), + ('261e1685-cf26-494c-b17c-3546e65f5620', 'Anna Bosh', '1974-11-14', '27 Colored Row'), + ('66774006-2371-4d5b-8518-2177bcf3f73e', 'Nick Somion', '1998-12-15', 'North sunny address 102'), + ('a3c1880c-674c-4d18-8f91-5d3608a2c937', 'Sam Query', '1990-04-22', '91 Western Roads'), + ('f98e4d74-0f68-4aac-89fd-047f1aaca6b6', 'Martin Miller', '1983-05-21', '3 Edgar Buildings'); + +INSERT INTO dbo.account (AccountId, DateCreated, AccountType, OwnerId) VALUES + ('03e91478-5608-4132-a753-d494dafce00b', '2003-12-15', 'Domestic', 'f98e4d74-0f68-4aac-89fd-047f1aaca6b6'), + ('356a5a9b-64bf-4de0-bc84-5395a1fdc9c4', '1996-02-15', 'Domestic', '261e1685-cf26-494c-b17c-3546e65f5620'), + ('371b93f2-f8c5-4a32-894a-fc672741aa5b', '1999-05-04', 'Domestic', '24fd81f8-d58a-4bcc-9f35-dc6cd5641906'), + ('670775db-ecc0-4b90-a9ab-37cd0d8e2801', '1999-12-21', 'Savings', '24fd81f8-d58a-4bcc-9f35-dc6cd5641906'), + ('a3fbad0b-7f48-4feb-8ac0-6d3bbc997bfc', '2010-05-28', 'Domestic', 'a3c1880c-674c-4d18-8f91-5d3608a2c937'), + ('aa15f658-04bb-4f73-82af-82db49d0fbef', '1999-05-12', 'Foreign', '24fd81f8-d58a-4bcc-9f35-dc6cd5641906'), + ('c6066eb0-53ca-43e1-97aa-3c2169eec659', '1996-02-16', 'Foreign', '261e1685-cf26-494c-b17c-3546e65f5620'), + ('eccadf79-85fe-402f-893c-32d3f03ed9b1', '2010-06-20', 'Foreign', 'a3c1880c-674c-4d18-8f91-5d3608a2c937'); diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer.Tests/AccountOwnerServer.Tests.csproj b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer.Tests/AccountOwnerServer.Tests.csproj new file mode 100644 index 000000000..338bac1ce --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer.Tests/AccountOwnerServer.Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + false + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer.Tests/DataShaperTests.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer.Tests/DataShaperTests.cs new file mode 100644 index 000000000..97b66e19a --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer.Tests/DataShaperTests.cs @@ -0,0 +1,61 @@ +using Entities.Helpers; +using Entities.Models; +using System; +using System.Linq; +using Xunit; + +namespace AccountOwnerServer.Tests; + +public class DataShaperTests +{ + private static readonly DataShaper Shaper = new(); + + [Fact] + public void GivenNoFields_WhenWeShape_ThenEveryPropertyIsKept() + { + var shaped = Shaper.ShapeData(SqlServerSample.Owners().First(), null); + + Assert.Equal(4, shaped.Entity.Count); + Assert.True(shaped.Entity.ContainsKey(nameof(Owner.Address))); + } + + [Fact] + public void GivenASubsetOfFields_WhenWeShape_ThenOnlyThoseComeBack() + { + var shaped = Shaper.ShapeData(SqlServerSample.Owners().First(), "name,dateOfBirth"); + + Assert.Equal(2, shaped.Entity.Count); + Assert.True(shaped.Entity.ContainsKey(nameof(Owner.Name))); + Assert.False(shaped.Entity.ContainsKey(nameof(Owner.Address))); + } + + [Fact] + public void GivenAnUnknownField_WhenWeShape_ThenItIsIgnored() + { + var shaped = Shaper.ShapeData(SqlServerSample.Owners().First(), "name,age"); + + Assert.Single(shaped.Entity); + Assert.True(shaped.Entity.ContainsKey(nameof(Owner.Name))); + } + + [Fact] + public void GivenAShapedEntity_WhenTheIdIsNotAmongTheFields_ThenItIsStillCarriedOutsideThePayload() + { + var owner = SqlServerSample.Owners().First(); + + var shaped = Shaper.ShapeData(owner, "name"); + + Assert.Equal(owner.Id, shaped.Id); + Assert.False(shaped.Entity.ContainsKey(nameof(Owner.Id))); + } + + [Fact] + public void GivenACollection_WhenWeShape_ThenEveryEntityIsShaped() + { + var shaped = Shaper.ShapeData(SqlServerSample.Owners(), "name").ToList(); + + Assert.Equal(5, shaped.Count); + Assert.All(shaped, s => Assert.Single(s.Entity)); + Assert.All(shaped, s => Assert.NotEqual(Guid.Empty, s.Id)); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer.Tests/OwnerParametersTests.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer.Tests/OwnerParametersTests.cs new file mode 100644 index 000000000..4ccdf0adb --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer.Tests/OwnerParametersTests.cs @@ -0,0 +1,41 @@ +using Entities.Models; +using Xunit; + +namespace AccountOwnerServer.Tests; + +public class OwnerParametersTests +{ + [Fact] + public void GivenTheSameMinAndMaxYear_WhenTheRangeIsValidated_ThenItIsAccepted() + { + var parameters = new OwnerParameters { MinYearOfBirth = 1990, MaxYearOfBirth = 1990 }; + + Assert.True(parameters.ValidYearRange); + } + + [Fact] + public void GivenAMaxYearBelowTheMinYear_WhenTheRangeIsValidated_ThenItIsRejected() + { + var parameters = new OwnerParameters { MinYearOfBirth = 1975, MaxYearOfBirth = 1974 }; + + Assert.False(parameters.ValidYearRange); + } + + [Fact] + public void GivenNoYearsAtAll_WhenTheRangeIsValidated_ThenItIsAccepted() + { + var parameters = new OwnerParameters(); + + Assert.Null(parameters.MinYearOfBirth); + Assert.Null(parameters.MaxYearOfBirth); + Assert.True(parameters.ValidYearRange); + } + + [Fact] + public void GivenOnlyAMinYear_WhenTheRangeIsValidated_ThenItIsAccepted() + { + var parameters = new OwnerParameters { MinYearOfBirth = 1975 }; + + Assert.True(parameters.ValidYearRange); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer.Tests/OwnerRepositorySqlServerTests.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer.Tests/OwnerRepositorySqlServerTests.cs new file mode 100644 index 000000000..0e6462bf2 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer.Tests/OwnerRepositorySqlServerTests.cs @@ -0,0 +1,128 @@ +using Entities.Helpers; +using Entities.Models; +using Repository; +using System; +using System.Linq; +using System.Threading.Tasks; +using Xunit; + +namespace AccountOwnerServer.Tests; + +/// +/// These run against a real SQL Server started by Testcontainers. They are skipped unless +/// CODEMAZE_SQLSERVER_TESTS=1 is set, because the SQL Server image is not guaranteed to be +/// available on every machine or CI runner. +/// +public class OwnerRepositorySqlServerTests +{ + [SqlServerFact] + public async Task GivenAPageSizeOfTwo_WhenWeAskForTheSecondPage_ThenWeGetTwoOwnersAndTheRightMetadata() + { + await using var sample = await SqlServerSample.StartAsync(); + await using var context = sample.CreateContext(); + + var repository = new OwnerRepository(context, new SortHelper(), new DataShaper()); + + var owners = await repository.GetOwners(new OwnerParameters { PageNumber = 2, PageSize = 2 }); + + Assert.Equal(2, owners.Count); + Assert.Equal(5, owners.TotalCount); + Assert.Equal(3, owners.TotalPages); + Assert.True(owners.HasPrevious); + Assert.True(owners.HasNext); + } + + [SqlServerFact] + public async Task GivenTheSameMinAndMaxYear_WhenWeFilter_ThenTheOwnerBornInThatYearComesBack() + { + await using var sample = await SqlServerSample.StartAsync(); + await using var context = sample.CreateContext(); + + var repository = new OwnerRepository(context, new SortHelper(), new DataShaper()); + + var owners = await repository.GetOwners(new OwnerParameters + { + MinYearOfBirth = 1990, + MaxYearOfBirth = 1990 + }); + + Assert.Equal(1, owners.TotalCount); + Assert.Equal("Sam Query", NameOf(owners.Single())); + } + + [SqlServerFact] + public async Task GivenOnlyAMinimumYear_WhenWeFilter_ThenTheUpperBoundIsOpen() + { + await using var sample = await SqlServerSample.StartAsync(); + await using var context = sample.CreateContext(); + + var repository = new OwnerRepository(context, new SortHelper(), new DataShaper()); + + var owners = await repository.GetOwners(new OwnerParameters { MinYearOfBirth = 1990 }); + + Assert.Equal(2, owners.TotalCount); + } + + [SqlServerFact] + public async Task GivenALowerCaseSearchTerm_WhenWeSearch_ThenTheDefaultCollationStillMatches() + { + await using var sample = await SqlServerSample.StartAsync(); + await using var context = sample.CreateContext(); + + var repository = new OwnerRepository(context, new SortHelper(), new DataShaper()); + + var owners = await repository.GetOwners(new OwnerParameters { SearchTerm = "anna" }); + + Assert.Equal(1, owners.TotalCount); + Assert.Equal("Anna Bosh", NameOf(owners.Single())); + } + + [SqlServerFact] + public async Task GivenAnUnknownOrderByField_WhenWeQuery_ThenThePageIsStillOrderedRatherThanFailing() + { + await using var sample = await SqlServerSample.StartAsync(); + await using var context = sample.CreateContext(); + + var repository = new OwnerRepository(context, new SortHelper(), new DataShaper()); + + var owners = await repository.GetOwners(new OwnerParameters { OrderBy = "age" }); + + Assert.Equal(5, owners.TotalCount); + Assert.Equal("Anna Bosh", NameOf(owners.First())); + } + + [SqlServerFact] + public async Task GivenAnUpperCaseDescToken_WhenWeQuery_ThenTheOrderIsDescending() + { + await using var sample = await SqlServerSample.StartAsync(); + await using var context = sample.CreateContext(); + + var repository = new OwnerRepository(context, new SortHelper(), new DataShaper()); + + var owners = await repository.GetOwners(new OwnerParameters { OrderBy = "name DESC" }); + + Assert.Equal("Sam Query", NameOf(owners.First())); + } + + [SqlServerFact] + public async Task GivenFieldsAndAPageSize_WhenWeQuery_ThenOnlyThePageIsShapedAndTheMetadataComesFromTheQuery() + { + await using var sample = await SqlServerSample.StartAsync(); + await using var context = sample.CreateContext(); + + var repository = new OwnerRepository(context, new SortHelper(), new DataShaper()); + + var owners = await repository.GetOwners(new OwnerParameters + { + Fields = "name", + PageSize = 2, + PageNumber = 1 + }); + + Assert.Equal(2, owners.Count); + Assert.Equal(5, owners.TotalCount); + Assert.Equal(3, owners.TotalPages); + } + + private static string NameOf(ShapedEntity owner) => owner.Entity[nameof(Owner.Name)].ToString()!; +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer.Tests/QueryStringParametersTests.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer.Tests/QueryStringParametersTests.cs new file mode 100644 index 000000000..0b3791e6f --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer.Tests/QueryStringParametersTests.cs @@ -0,0 +1,39 @@ +using Entities.Models; +using Xunit; + +namespace AccountOwnerServer.Tests; + +public class QueryStringParametersTests +{ + [Theory] + [InlineData(0, 1)] + [InlineData(-3, 1)] + [InlineData(2, 2)] + public void GivenAPageNumber_WhenItIsSet_ThenItNeverDropsBelowOne(int requested, int expected) + { + var parameters = new OwnerParameters { PageNumber = requested }; + + Assert.Equal(expected, parameters.PageNumber); + } + + [Theory] + [InlineData(0, 1)] + [InlineData(-10, 1)] + [InlineData(7, 7)] + [InlineData(500, 50)] + public void GivenAPageSize_WhenItIsSet_ThenItIsClampedBetweenOneAndFifty(int requested, int expected) + { + var parameters = new OwnerParameters { PageSize = requested }; + + Assert.Equal(expected, parameters.PageSize); + } + + [Fact] + public void GivenNoQueryString_WhenParametersAreCreated_ThenTheDefaultsAreOneAndTen() + { + var parameters = new OwnerParameters(); + + Assert.Equal(1, parameters.PageNumber); + Assert.Equal(10, parameters.PageSize); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer.Tests/SearchExtensionTests.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer.Tests/SearchExtensionTests.cs new file mode 100644 index 000000000..e83fe53cc --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer.Tests/SearchExtensionTests.cs @@ -0,0 +1,48 @@ +using Entities.Models; +using Repository.Extensions; +using System.Linq; +using Xunit; + +namespace AccountOwnerServer.Tests; + +public class SearchExtensionTests +{ + private static IQueryable Owners() => SqlServerSample.Owners().AsQueryable(); + + [Fact] + public void GivenABlankSearchTerm_WhenWeSearch_ThenTheQueryIsReturnedUntouched() + { + var owners = Owners(); + + Assert.Same(owners, owners.Search(null)); + Assert.Same(owners, owners.Search(string.Empty)); + Assert.Same(owners, owners.Search(" ")); + } + + [Fact] + public void GivenANameFragment_WhenWeSearch_ThenTheMatchingOwnerComesBack() + { + var result = Owners().Search("Anna").ToList(); + + Assert.Single(result); + Assert.Equal("Anna Bosh", result[0].Name); + } + + [Fact] + public void GivenAnAddressFragment_WhenWeSearch_ThenTheColumnIsSearchedToo() + { + var result = Owners().Search("Wellfield").ToList(); + + Assert.Single(result); + Assert.Equal("John Keen", result[0].Name); + } + + [Fact] + public void GivenAPaddedSearchTerm_WhenWeSearch_ThenItIsTrimmedBeforeMatching() + { + var result = Owners().Search(" Somion ").ToList(); + + Assert.Single(result); + Assert.Equal("Nick Somion", result[0].Name); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer.Tests/SortHelperTests.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer.Tests/SortHelperTests.cs new file mode 100644 index 000000000..685ec28ad --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer.Tests/SortHelperTests.cs @@ -0,0 +1,61 @@ +using Entities.Helpers; +using Entities.Models; +using System.Linq; +using Xunit; + +namespace AccountOwnerServer.Tests; + +public class SortHelperTests +{ + private static IQueryable Owners() => SqlServerSample.Owners().AsQueryable(); + + private static readonly SortHelper Sorter = new(); + + [Fact] + public void GivenABlankOrderBy_WhenWeSort_ThenTheQueryIsReturnedUntouched() + { + var owners = Owners(); + + Assert.Same(owners, Sorter.ApplySort(owners, null)); + Assert.Same(owners, Sorter.ApplySort(owners, " ")); + } + + [Fact] + public void GivenOnlyUnknownFields_WhenWeSort_ThenTheQueryIsReturnedUntouchedRatherThanThrowing() + { + var owners = Owners(); + + var sorted = Sorter.ApplySort(owners, "age"); + + Assert.Same(owners, sorted); + } + + [Fact] + public void GivenAKnownField_WhenWeSort_ThenTheRowsComeBackInThatOrder() + { + var sorted = Sorter.ApplySort(Owners(), "dateOfBirth").ToList(); + + Assert.Equal("Anna Bosh", sorted.First().Name); + Assert.Equal("Nick Somion", sorted.Last().Name); + } + + [Theory] + [InlineData("name desc")] + [InlineData("name DESC")] + [InlineData("name Desc")] + public void GivenDescInAnyCasing_WhenWeSort_ThenTheOrderIsDescending(string orderBy) + { + var sorted = Sorter.ApplySort(Owners(), orderBy).ToList(); + + Assert.Equal("Sam Query", sorted.First().Name); + Assert.Equal("Anna Bosh", sorted.Last().Name); + } + + [Fact] + public void GivenAKnownAndAnUnknownField_WhenWeSort_ThenTheKnownOneStillApplies() + { + var sorted = Sorter.ApplySort(Owners(), "age,name desc").ToList(); + + Assert.Equal("Sam Query", sorted.First().Name); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer.Tests/SqlServerFactAttribute.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer.Tests/SqlServerFactAttribute.cs new file mode 100644 index 000000000..6ae7fafae --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer.Tests/SqlServerFactAttribute.cs @@ -0,0 +1,20 @@ +using System; +using Xunit; + +namespace AccountOwnerServer.Tests; + +/// +/// A fact that runs only when SQL Server integration tests are switched on with +/// CODEMAZE_SQLSERVER_TESTS=1. The tests need a working Docker daemon: Testcontainers +/// starts mcr.microsoft.com/mssql/server for them. +/// +public sealed class SqlServerFactAttribute : FactAttribute +{ + public SqlServerFactAttribute() + { + if (Environment.GetEnvironmentVariable("CODEMAZE_SQLSERVER_TESTS") != "1") + { + Skip = "Set CODEMAZE_SQLSERVER_TESTS=1 (and have Docker running) to run the SQL Server tests."; + } + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer.Tests/SqlServerSample.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer.Tests/SqlServerSample.cs new file mode 100644 index 000000000..e557d6730 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer.Tests/SqlServerSample.cs @@ -0,0 +1,73 @@ +using Entities; +using Entities.Models; +using Microsoft.EntityFrameworkCore; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Testcontainers.MsSql; + +namespace AccountOwnerServer.Tests; + +/// +/// A throwaway SQL Server container seeded with the five owners and eight accounts the +/// articles use. Each test owns its own container, so nothing is created when the tests +/// are skipped. +/// +public sealed class SqlServerSample : IAsyncDisposable +{ + private readonly MsSqlContainer _container; + + private SqlServerSample(MsSqlContainer container) + { + _container = container; + } + + public static async Task StartAsync() + { + var container = new MsSqlBuilder("mcr.microsoft.com/mssql/server:2022-latest").Build(); + await container.StartAsync(); + + var sample = new SqlServerSample(container); + + await using var context = sample.CreateContext(); + await context.Database.EnsureCreatedAsync(); + + context.Owners.AddRange(Owners()); + context.Accounts.AddRange(Accounts()); + await context.SaveChangesAsync(); + + return sample; + } + + public RepositoryContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseSqlServer(_container.GetConnectionString()) + .Options; + + return new RepositoryContext(options); + } + + public async ValueTask DisposeAsync() => await _container.DisposeAsync(); + + public static List Owners() => + [ + new() { Id = Guid.Parse("24fd81f8-d58a-4bcc-9f35-dc6cd5641906"), Name = "John Keen", DateOfBirth = new DateTime(1980, 12, 5), Address = "61 Wellfield Road" }, + new() { Id = Guid.Parse("261e1685-cf26-494c-b17c-3546e65f5620"), Name = "Anna Bosh", DateOfBirth = new DateTime(1974, 11, 14), Address = "27 Colored Row" }, + new() { Id = Guid.Parse("66774006-2371-4d5b-8518-2177bcf3f73e"), Name = "Nick Somion", DateOfBirth = new DateTime(1998, 12, 15), Address = "North sunny address 102" }, + new() { Id = Guid.Parse("a3c1880c-674c-4d18-8f91-5d3608a2c937"), Name = "Sam Query", DateOfBirth = new DateTime(1990, 4, 22), Address = "91 Western Roads" }, + new() { Id = Guid.Parse("f98e4d74-0f68-4aac-89fd-047f1aaca6b6"), Name = "Martin Miller", DateOfBirth = new DateTime(1983, 5, 21), Address = "3 Edgar Buildings" } + ]; + + public static List Accounts() => + [ + new() { Id = Guid.Parse("03e91478-5608-4132-a753-d494dafce00b"), DateCreated = new DateTime(2003, 12, 15), AccountType = "Domestic", OwnerId = Guid.Parse("f98e4d74-0f68-4aac-89fd-047f1aaca6b6") }, + new() { Id = Guid.Parse("356a5a9b-64bf-4de0-bc84-5395a1fdc9c4"), DateCreated = new DateTime(1996, 2, 15), AccountType = "Domestic", OwnerId = Guid.Parse("261e1685-cf26-494c-b17c-3546e65f5620") }, + new() { Id = Guid.Parse("371b93f2-f8c5-4a32-894a-fc672741aa5b"), DateCreated = new DateTime(1999, 5, 4), AccountType = "Domestic", OwnerId = Guid.Parse("24fd81f8-d58a-4bcc-9f35-dc6cd5641906") }, + new() { Id = Guid.Parse("670775db-ecc0-4b90-a9ab-37cd0d8e2801"), DateCreated = new DateTime(1999, 12, 21), AccountType = "Savings", OwnerId = Guid.Parse("24fd81f8-d58a-4bcc-9f35-dc6cd5641906") }, + new() { Id = Guid.Parse("a3fbad0b-7f48-4feb-8ac0-6d3bbc997bfc"), DateCreated = new DateTime(2010, 5, 28), AccountType = "Domestic", OwnerId = Guid.Parse("a3c1880c-674c-4d18-8f91-5d3608a2c937") }, + new() { Id = Guid.Parse("aa15f658-04bb-4f73-82af-82db49d0fbef"), DateCreated = new DateTime(1999, 5, 12), AccountType = "Foreign", OwnerId = Guid.Parse("24fd81f8-d58a-4bcc-9f35-dc6cd5641906") }, + new() { Id = Guid.Parse("c6066eb0-53ca-43e1-97aa-3c2169eec659"), DateCreated = new DateTime(1996, 2, 16), AccountType = "Foreign", OwnerId = Guid.Parse("261e1685-cf26-494c-b17c-3546e65f5620") }, + new() { Id = Guid.Parse("eccadf79-85fe-402f-893c-32d3f03ed9b1"), DateCreated = new DateTime(2010, 6, 20), AccountType = "Foreign", OwnerId = Guid.Parse("a3c1880c-674c-4d18-8f91-5d3608a2c937") } + ]; +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer.Tests/ValidateMediaTypeAttributeTests.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer.Tests/ValidateMediaTypeAttributeTests.cs new file mode 100644 index 000000000..174612cec --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer.Tests/ValidateMediaTypeAttributeTests.cs @@ -0,0 +1,51 @@ +using AccountOwnerServer.Filters; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Routing; +using Microsoft.Net.Http.Headers; +using System.Collections.Generic; +using Xunit; + +namespace AccountOwnerServer.Tests; + +public class ValidateMediaTypeAttributeTests +{ + private static ActionExecutingContext ContextWith(string? accept) + { + var httpContext = new DefaultHttpContext(); + + if (accept is not null) + httpContext.Request.Headers.Accept = accept; + + var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + + return new ActionExecutingContext(actionContext, [], new Dictionary(), null!); + } + + [Fact] + public void GivenNoAcceptHeader_WhenTheFilterRuns_ThenTheRequestIsRejected() + { + var context = ContextWith(null); + + new ValidateMediaTypeAttribute().OnActionExecuting(context); + + Assert.IsType(context.Result); + } + + [Fact] + public void GivenAHateoasAcceptHeader_WhenTheFilterRuns_ThenTheParsedMediaTypeIsStashed() + { + var context = ContextWith("application/vnd.codemaze.hateoas+json"); + + new ValidateMediaTypeAttribute().OnActionExecuting(context); + + Assert.Null(context.Result); + + var mediaType = Assert.IsType(context.HttpContext.Items["AcceptHeaderMediaType"]); + + Assert.Equal("vnd.codemaze.hateoas", mediaType.SubTypeWithoutSuffix.ToString()); + Assert.Equal("json", mediaType.Suffix.ToString()); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer.sln b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer.sln new file mode 100644 index 000000000..69f756b19 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer.sln @@ -0,0 +1,104 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AccountOwnerServer", "AccountOwnerServer\AccountOwnerServer.csproj", "{3C1807CB-FFC7-4944-BCAF-D85A62A62417}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LoggerService", "LoggerService\LoggerService.csproj", "{3DE984B7-DA5B-48B4-816A-42E5F774B8E0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contracts", "Contracts\Contracts.csproj", "{1F5692FA-959D-427A-8BE2-95AF970461E2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Entities", "Entities\Entities.csproj", "{E92DD7A1-F47B-4B9F-AB64-24F497E9E6CE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Repository", "Repository\Repository.csproj", "{EE48F109-2582-4498-93DD-EABF2564F057}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AccountOwnerServer.Tests", "AccountOwnerServer.Tests\AccountOwnerServer.Tests.csproj", "{D0A3A02F-B4DB-4300-83BA-FB09D997917C}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {3C1807CB-FFC7-4944-BCAF-D85A62A62417}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3C1807CB-FFC7-4944-BCAF-D85A62A62417}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3C1807CB-FFC7-4944-BCAF-D85A62A62417}.Debug|x64.ActiveCfg = Debug|Any CPU + {3C1807CB-FFC7-4944-BCAF-D85A62A62417}.Debug|x64.Build.0 = Debug|Any CPU + {3C1807CB-FFC7-4944-BCAF-D85A62A62417}.Debug|x86.ActiveCfg = Debug|Any CPU + {3C1807CB-FFC7-4944-BCAF-D85A62A62417}.Debug|x86.Build.0 = Debug|Any CPU + {3C1807CB-FFC7-4944-BCAF-D85A62A62417}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3C1807CB-FFC7-4944-BCAF-D85A62A62417}.Release|Any CPU.Build.0 = Release|Any CPU + {3C1807CB-FFC7-4944-BCAF-D85A62A62417}.Release|x64.ActiveCfg = Release|Any CPU + {3C1807CB-FFC7-4944-BCAF-D85A62A62417}.Release|x64.Build.0 = Release|Any CPU + {3C1807CB-FFC7-4944-BCAF-D85A62A62417}.Release|x86.ActiveCfg = Release|Any CPU + {3C1807CB-FFC7-4944-BCAF-D85A62A62417}.Release|x86.Build.0 = Release|Any CPU + {3DE984B7-DA5B-48B4-816A-42E5F774B8E0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3DE984B7-DA5B-48B4-816A-42E5F774B8E0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3DE984B7-DA5B-48B4-816A-42E5F774B8E0}.Debug|x64.ActiveCfg = Debug|Any CPU + {3DE984B7-DA5B-48B4-816A-42E5F774B8E0}.Debug|x64.Build.0 = Debug|Any CPU + {3DE984B7-DA5B-48B4-816A-42E5F774B8E0}.Debug|x86.ActiveCfg = Debug|Any CPU + {3DE984B7-DA5B-48B4-816A-42E5F774B8E0}.Debug|x86.Build.0 = Debug|Any CPU + {3DE984B7-DA5B-48B4-816A-42E5F774B8E0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3DE984B7-DA5B-48B4-816A-42E5F774B8E0}.Release|Any CPU.Build.0 = Release|Any CPU + {3DE984B7-DA5B-48B4-816A-42E5F774B8E0}.Release|x64.ActiveCfg = Release|Any CPU + {3DE984B7-DA5B-48B4-816A-42E5F774B8E0}.Release|x64.Build.0 = Release|Any CPU + {3DE984B7-DA5B-48B4-816A-42E5F774B8E0}.Release|x86.ActiveCfg = Release|Any CPU + {3DE984B7-DA5B-48B4-816A-42E5F774B8E0}.Release|x86.Build.0 = Release|Any CPU + {1F5692FA-959D-427A-8BE2-95AF970461E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1F5692FA-959D-427A-8BE2-95AF970461E2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1F5692FA-959D-427A-8BE2-95AF970461E2}.Debug|x64.ActiveCfg = Debug|Any CPU + {1F5692FA-959D-427A-8BE2-95AF970461E2}.Debug|x64.Build.0 = Debug|Any CPU + {1F5692FA-959D-427A-8BE2-95AF970461E2}.Debug|x86.ActiveCfg = Debug|Any CPU + {1F5692FA-959D-427A-8BE2-95AF970461E2}.Debug|x86.Build.0 = Debug|Any CPU + {1F5692FA-959D-427A-8BE2-95AF970461E2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1F5692FA-959D-427A-8BE2-95AF970461E2}.Release|Any CPU.Build.0 = Release|Any CPU + {1F5692FA-959D-427A-8BE2-95AF970461E2}.Release|x64.ActiveCfg = Release|Any CPU + {1F5692FA-959D-427A-8BE2-95AF970461E2}.Release|x64.Build.0 = Release|Any CPU + {1F5692FA-959D-427A-8BE2-95AF970461E2}.Release|x86.ActiveCfg = Release|Any CPU + {1F5692FA-959D-427A-8BE2-95AF970461E2}.Release|x86.Build.0 = Release|Any CPU + {E92DD7A1-F47B-4B9F-AB64-24F497E9E6CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E92DD7A1-F47B-4B9F-AB64-24F497E9E6CE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E92DD7A1-F47B-4B9F-AB64-24F497E9E6CE}.Debug|x64.ActiveCfg = Debug|Any CPU + {E92DD7A1-F47B-4B9F-AB64-24F497E9E6CE}.Debug|x64.Build.0 = Debug|Any CPU + {E92DD7A1-F47B-4B9F-AB64-24F497E9E6CE}.Debug|x86.ActiveCfg = Debug|Any CPU + {E92DD7A1-F47B-4B9F-AB64-24F497E9E6CE}.Debug|x86.Build.0 = Debug|Any CPU + {E92DD7A1-F47B-4B9F-AB64-24F497E9E6CE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E92DD7A1-F47B-4B9F-AB64-24F497E9E6CE}.Release|Any CPU.Build.0 = Release|Any CPU + {E92DD7A1-F47B-4B9F-AB64-24F497E9E6CE}.Release|x64.ActiveCfg = Release|Any CPU + {E92DD7A1-F47B-4B9F-AB64-24F497E9E6CE}.Release|x64.Build.0 = Release|Any CPU + {E92DD7A1-F47B-4B9F-AB64-24F497E9E6CE}.Release|x86.ActiveCfg = Release|Any CPU + {E92DD7A1-F47B-4B9F-AB64-24F497E9E6CE}.Release|x86.Build.0 = Release|Any CPU + {EE48F109-2582-4498-93DD-EABF2564F057}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EE48F109-2582-4498-93DD-EABF2564F057}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EE48F109-2582-4498-93DD-EABF2564F057}.Debug|x64.ActiveCfg = Debug|Any CPU + {EE48F109-2582-4498-93DD-EABF2564F057}.Debug|x64.Build.0 = Debug|Any CPU + {EE48F109-2582-4498-93DD-EABF2564F057}.Debug|x86.ActiveCfg = Debug|Any CPU + {EE48F109-2582-4498-93DD-EABF2564F057}.Debug|x86.Build.0 = Debug|Any CPU + {EE48F109-2582-4498-93DD-EABF2564F057}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EE48F109-2582-4498-93DD-EABF2564F057}.Release|Any CPU.Build.0 = Release|Any CPU + {EE48F109-2582-4498-93DD-EABF2564F057}.Release|x64.ActiveCfg = Release|Any CPU + {EE48F109-2582-4498-93DD-EABF2564F057}.Release|x64.Build.0 = Release|Any CPU + {EE48F109-2582-4498-93DD-EABF2564F057}.Release|x86.ActiveCfg = Release|Any CPU + {EE48F109-2582-4498-93DD-EABF2564F057}.Release|x86.Build.0 = Release|Any CPU + {D0A3A02F-B4DB-4300-83BA-FB09D997917C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D0A3A02F-B4DB-4300-83BA-FB09D997917C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D0A3A02F-B4DB-4300-83BA-FB09D997917C}.Debug|x64.ActiveCfg = Debug|Any CPU + {D0A3A02F-B4DB-4300-83BA-FB09D997917C}.Debug|x64.Build.0 = Debug|Any CPU + {D0A3A02F-B4DB-4300-83BA-FB09D997917C}.Debug|x86.ActiveCfg = Debug|Any CPU + {D0A3A02F-B4DB-4300-83BA-FB09D997917C}.Debug|x86.Build.0 = Debug|Any CPU + {D0A3A02F-B4DB-4300-83BA-FB09D997917C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D0A3A02F-B4DB-4300-83BA-FB09D997917C}.Release|Any CPU.Build.0 = Release|Any CPU + {D0A3A02F-B4DB-4300-83BA-FB09D997917C}.Release|x64.ActiveCfg = Release|Any CPU + {D0A3A02F-B4DB-4300-83BA-FB09D997917C}.Release|x64.Build.0 = Release|Any CPU + {D0A3A02F-B4DB-4300-83BA-FB09D997917C}.Release|x86.ActiveCfg = Release|Any CPU + {D0A3A02F-B4DB-4300-83BA-FB09D997917C}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer/AccountOwnerServer.csproj b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer/AccountOwnerServer.csproj new file mode 100644 index 000000000..8d9f2e4a6 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer/AccountOwnerServer.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + enable + + + + + + + + + + + + diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer/Controllers/AccountController.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer/Controllers/AccountController.cs new file mode 100644 index 000000000..3f393a2dc --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer/Controllers/AccountController.cs @@ -0,0 +1,114 @@ +using AccountOwnerServer.Filters; +using Contracts; +using Entities.Extensions; +using Entities.Models; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.Net.Http.Headers; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using System; + +namespace AccountOwnerServer.Controllers; + +[Route("api/owners/{ownerId}/accounts")] +[ApiController] +public class AccountController : ControllerBase +{ + private readonly ILoggerManager _logger; + private readonly IRepositoryWrapper _repository; + private readonly LinkGenerator _linkGenerator; + + public AccountController(ILoggerManager logger, + IRepositoryWrapper repository, + LinkGenerator linkGenerator) + { + _logger = logger; + _repository = repository; + _linkGenerator = linkGenerator; + } + + [HttpGet] + [ServiceFilter(typeof(ValidateMediaTypeAttribute))] + public async Task GetAccountsForOwner(Guid ownerId, [FromQuery] AccountParameters parameters) + { + var accounts = await _repository.Account.GetAccountsByOwner(ownerId, parameters); + + var metadata = new + { + accounts.TotalCount, + accounts.PageSize, + accounts.CurrentPage, + accounts.TotalPages, + accounts.HasNext, + accounts.HasPrevious + }; + + Response.Headers["X-Pagination"] = JsonSerializer.Serialize(metadata); + + _logger.LogInfo($"Returned {accounts.TotalCount} accounts from database."); + + var shapedAccounts = accounts.Select(a => a.Entity).ToList(); + + var mediaType = (MediaTypeHeaderValue)HttpContext.Items["AcceptHeaderMediaType"]!; + + if (!mediaType.SubTypeWithoutSuffix.EndsWith("hateoas", StringComparison.InvariantCultureIgnoreCase)) + return Ok(shapedAccounts); + + for (var index = 0; index < accounts.Count; index++) + shapedAccounts[index].Add("Links", CreateLinksForAccount(ownerId, accounts[index].Id, parameters.Fields)); + + var accountsWrapper = new LinkCollectionWrapper(shapedAccounts); + + return Ok(CreateLinksForAccounts(accountsWrapper)); + } + + [HttpGet("{id}")] + [ServiceFilter(typeof(ValidateMediaTypeAttribute))] + public IActionResult GetAccountForOwner(Guid ownerId, Guid id, [FromQuery] string? fields) + { + var account = _repository.Account.GetAccountByOwner(ownerId, id, fields); + + if (account.Id == Guid.Empty) + { + _logger.LogError($"Account with id: {id}, hasn't been found in db."); + return NotFound(); + } + + var mediaType = (MediaTypeHeaderValue)HttpContext.Items["AcceptHeaderMediaType"]!; + + if (!mediaType.SubTypeWithoutSuffix.EndsWith("hateoas", StringComparison.InvariantCultureIgnoreCase)) + { + _logger.LogInfo($"Returned a shaped account with id: {id}"); + return Ok(account.Entity); + } + + account.Entity.Add("Links", CreateLinksForAccount(ownerId, id, fields)); + + return Ok(account.Entity); + } + + private List CreateLinksForAccount(Guid ownerId, Guid id, string? fields = "") + { + var links = new List + { + new(_linkGenerator.GetUriByAction(HttpContext, nameof(GetAccountForOwner), values: new { ownerId, id, fields })!, + "self", + "GET") + }; + + return links; + } + + private LinkCollectionWrapper CreateLinksForAccounts(LinkCollectionWrapper accountsWrapper) + { + accountsWrapper.Links.Add(new Link( + _linkGenerator.GetUriByAction(HttpContext, nameof(GetAccountsForOwner), values: null)!, + "self", + "GET")); + + return accountsWrapper; + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer/Controllers/OwnerController.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer/Controllers/OwnerController.cs new file mode 100644 index 000000000..c15f2b4a9 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer/Controllers/OwnerController.cs @@ -0,0 +1,197 @@ +using AccountOwnerServer.Filters; +using Contracts; +using Entities.Extensions; +using Entities.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.Net.Http.Headers; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using System; + +namespace AccountOwnerServer.Controllers; + +[Route("api/owners")] +[ApiController] +public class OwnerController : ControllerBase +{ + private readonly ILoggerManager _logger; + private readonly IRepositoryWrapper _repository; + private readonly LinkGenerator _linkGenerator; + + public OwnerController(ILoggerManager logger, + IRepositoryWrapper repository, + LinkGenerator linkGenerator) + { + _logger = logger; + _repository = repository; + _linkGenerator = linkGenerator; + } + + [HttpGet] + [ServiceFilter(typeof(ValidateMediaTypeAttribute))] + public async Task GetOwners([FromQuery] OwnerParameters ownerParameters) + { + if (!ownerParameters.ValidYearRange) + { + return Problem( + detail: "maxYearOfBirth must be greater than or equal to minYearOfBirth.", + statusCode: StatusCodes.Status400BadRequest); + } + + var owners = await _repository.Owner.GetOwners(ownerParameters); + + var metadata = new + { + owners.TotalCount, + owners.PageSize, + owners.CurrentPage, + owners.TotalPages, + owners.HasNext, + owners.HasPrevious + }; + + Response.Headers["X-Pagination"] = JsonSerializer.Serialize(metadata); + + _logger.LogInfo($"Returned {owners.TotalCount} owners from database."); + + var shapedOwners = owners.Select(o => o.Entity).ToList(); + + var mediaType = (MediaTypeHeaderValue)HttpContext.Items["AcceptHeaderMediaType"]!; + + if (!mediaType.SubTypeWithoutSuffix.EndsWith("hateoas", StringComparison.InvariantCultureIgnoreCase)) + return Ok(shapedOwners); + + for (var index = 0; index < owners.Count; index++) + shapedOwners[index].Add("Links", CreateLinksForOwner(owners[index].Id, ownerParameters.Fields)); + + var ownersWrapper = new LinkCollectionWrapper(shapedOwners); + + return Ok(CreateLinksForOwners(ownersWrapper)); + } + + [HttpGet("{id}", Name = "OwnerById")] + [ServiceFilter(typeof(ValidateMediaTypeAttribute))] + public IActionResult GetOwnerById(Guid id, [FromQuery] string? fields) + { + var owner = _repository.Owner.GetOwnerById(id, fields); + + if (owner.Id == Guid.Empty) + { + _logger.LogError($"Owner with id: {id}, hasn't been found in db."); + return NotFound(); + } + + var mediaType = (MediaTypeHeaderValue)HttpContext.Items["AcceptHeaderMediaType"]!; + + if (!mediaType.SubTypeWithoutSuffix.EndsWith("hateoas", StringComparison.InvariantCultureIgnoreCase)) + { + _logger.LogInfo($"Returned shaped owner with id: {id}"); + return Ok(owner.Entity); + } + + owner.Entity.Add("Links", CreateLinksForOwner(owner.Id, fields)); + + return Ok(owner.Entity); + } + + [HttpPost] + public IActionResult CreateOwner([FromBody] Owner owner) + { + if (owner.IsObjectNull()) + { + _logger.LogError("Owner object sent from client is null."); + return BadRequest("Owner object is null"); + } + + if (!ModelState.IsValid) + { + _logger.LogError("Invalid owner object sent from client."); + return BadRequest("Invalid model object"); + } + + _repository.Owner.CreateOwner(owner); + _repository.Save(); + + return CreatedAtRoute("OwnerById", new { id = owner.Id }, owner); + } + + [HttpPut("{id}")] + public IActionResult UpdateOwner(Guid id, [FromBody] Owner owner) + { + if (owner.IsObjectNull()) + { + _logger.LogError("Owner object sent from client is null."); + return BadRequest("Owner object is null"); + } + + if (!ModelState.IsValid) + { + _logger.LogError("Invalid owner object sent from client."); + return BadRequest("Invalid model object"); + } + + var dbOwner = _repository.Owner.GetOwnerById(id); + + if (dbOwner.IsEmptyObject()) + { + _logger.LogError($"Owner with id: {id}, hasn't been found in db."); + return NotFound(); + } + + _repository.Owner.UpdateOwner(dbOwner, owner); + _repository.Save(); + + return NoContent(); + } + + [HttpDelete("{id}")] + public IActionResult DeleteOwner(Guid id) + { + var owner = _repository.Owner.GetOwnerById(id); + + if (owner.IsEmptyObject()) + { + _logger.LogError($"Owner with id: {id}, hasn't been found in db."); + return NotFound(); + } + + _repository.Owner.DeleteOwner(owner); + _repository.Save(); + + return NoContent(); + } + + private List CreateLinksForOwner(Guid id, string? fields = "") + { + var links = new List + { + new(_linkGenerator.GetUriByAction(HttpContext, nameof(GetOwnerById), values: new { id, fields })!, + "self", + "GET"), + + new(_linkGenerator.GetUriByAction(HttpContext, nameof(DeleteOwner), values: new { id })!, + "delete_owner", + "DELETE"), + + new(_linkGenerator.GetUriByAction(HttpContext, nameof(UpdateOwner), values: new { id })!, + "update_owner", + "PUT") + }; + + return links; + } + + private LinkCollectionWrapper CreateLinksForOwners(LinkCollectionWrapper ownersWrapper) + { + ownersWrapper.Links.Add(new Link( + _linkGenerator.GetUriByAction(HttpContext, nameof(GetOwners), values: null)!, + "self", + "GET")); + + return ownersWrapper; + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer/Extensions/ServiceExtensions.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer/Extensions/ServiceExtensions.cs new file mode 100644 index 000000000..1590440c7 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer/Extensions/ServiceExtensions.cs @@ -0,0 +1,67 @@ +using AccountOwnerServer.Filters; +using Contracts; +using Entities.Helpers; +using Entities.Models; +using Entities; +using LoggerService; +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Repository; +using System.Linq; + +namespace AccountOwnerServer.Extensions; + +public static class ServiceExtensions +{ + public static void ConfigureCors(this IServiceCollection services) => + services.AddCors(options => + options.AddPolicy("CorsPolicy", + builder => builder.WithOrigins("http://localhost:5000", "https://localhost:5001") + .AllowAnyMethod() + .AllowAnyHeader() + .AllowCredentials() + .WithExposedHeaders("X-Pagination"))); + + public static void ConfigureLoggerService(this IServiceCollection services) => + services.AddSingleton(); + + public static void ConfigureSqlContext(this IServiceCollection services, IConfiguration config) + { + var connectionString = config["sqlconnection:connectionString"]; + services.AddDbContext(o => o.UseSqlServer(connectionString)); + } + + public static void ConfigureRepositoryWrapper(this IServiceCollection services) + { + services.AddScoped, SortHelper>(); + services.AddScoped, SortHelper>(); + + services.AddSingleton, DataShaper>(); + services.AddSingleton, DataShaper>(); + services.AddScoped(); + } + + public static void RegisterFilters(this IServiceCollection services) => + services.AddScoped(); + + public static void AddCustomMediaTypes(this IServiceCollection services) + { + services.Configure(config => + { + var systemTextJsonOutputFormatter = config.OutputFormatters + .OfType().FirstOrDefault(); + + systemTextJsonOutputFormatter?.SupportedMediaTypes + .Add("application/vnd.codemaze.hateoas+json"); + + var xmlOutputFormatter = config.OutputFormatters + .OfType().FirstOrDefault(); + + xmlOutputFormatter?.SupportedMediaTypes + .Add("application/vnd.codemaze.hateoas+xml"); + }); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer/Filters/ValidateMediaTypeAttribute.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer/Filters/ValidateMediaTypeAttribute.cs new file mode 100644 index 000000000..b65eb8e5d --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer/Filters/ValidateMediaTypeAttribute.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Net.Http.Headers; +using System.Linq; + +namespace AccountOwnerServer.Filters; + +public class ValidateMediaTypeAttribute : IActionFilter +{ + public void OnActionExecuting(ActionExecutingContext context) + { + if (context.HttpContext.Request.Headers.Accept.Count == 0) + { + context.Result = new BadRequestObjectResult("Accept header is missing."); + return; + } + + var mediaType = context.HttpContext.Request.Headers.Accept.FirstOrDefault(); + + if (!MediaTypeHeaderValue.TryParse(mediaType, out var outMediaType)) + { + context.Result = new BadRequestObjectResult( + "Media type not present. Please add Accept header with the required media type."); + return; + } + + context.HttpContext.Items["AcceptHeaderMediaType"] = outMediaType; + } + + public void OnActionExecuted(ActionExecutedContext context) + { + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer/Program.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer/Program.cs new file mode 100644 index 000000000..bcbb17642 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer/Program.cs @@ -0,0 +1,68 @@ +using AccountOwnerServer.Extensions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using NLog; +using System.Net; + +LogManager.Setup().LoadConfigurationFromFile("nlog.config"); + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.ConfigureCors(); +builder.Services.ConfigureLoggerService(); +builder.Services.ConfigureSqlContext(builder.Configuration); +builder.Services.ConfigureRepositoryWrapper(); + +builder.Services.RegisterFilters(); + +builder.Services.AddControllers(config => +{ + config.RespectBrowserAcceptHeader = true; + config.ReturnHttpNotAcceptable = true; +}).AddXmlDataContractSerializerFormatters(); + +builder.Services.AddCustomMediaTypes(); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) + app.UseDeveloperExceptionPage(); +else + app.UseHsts(); + +app.UseExceptionHandler(appError => +{ + appError.Run(async context => + { + context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; + context.Response.ContentType = "application/json"; + + var contextFeature = context.Features.Get(); + + if (contextFeature is not null) + { + await context.Response.WriteAsJsonAsync(new + { + context.Response.StatusCode, + Message = "Internal Server Error." + }); + } + }); +}); + +app.UseHttpsRedirection(); + +app.UseCors("CorsPolicy"); + +app.UseForwardedHeaders(new ForwardedHeadersOptions +{ + ForwardedHeaders = ForwardedHeaders.All +}); + +app.MapControllers(); + +app.Run(); diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer/Properties/launchSettings.json b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer/Properties/launchSettings.json new file mode 100644 index 000000000..a82b9223a --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "AccountOwnerServer": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "api/owners", + "applicationUrl": "https://localhost:5001;http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer/appsettings.Development.json b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer/appsettings.Development.json new file mode 100644 index 000000000..e203e9407 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + } + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer/appsettings.json b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer/appsettings.json new file mode 100644 index 000000000..0096cb3d4 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer/appsettings.json @@ -0,0 +1,11 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning" + } + }, + "sqlconnection": { + "connectionString": "Server=(localdb)\\mssqllocaldb;Database=AccountOwner;Trusted_Connection=True;TrustServerCertificate=True;" + }, + "AllowedHosts": "*" +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer/nlog.config b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer/nlog.config new file mode 100644 index 000000000..52f675aa6 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/AccountOwnerServer/nlog.config @@ -0,0 +1,17 @@ + + + + + + + + + + + diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Contracts/Contracts.csproj b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Contracts/Contracts.csproj new file mode 100644 index 000000000..b201bbf27 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Contracts/Contracts.csproj @@ -0,0 +1,12 @@ + + + + net10.0 + enable + + + + + + + diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Contracts/IAccountRepository.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Contracts/IAccountRepository.cs new file mode 100644 index 000000000..1c50f7f01 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Contracts/IAccountRepository.cs @@ -0,0 +1,13 @@ +using Entities.Helpers; +using Entities.Models; +using System; +using System.Threading.Tasks; + +namespace Contracts; + +public interface IAccountRepository : IRepositoryBase +{ + Task> GetAccountsByOwner(Guid ownerId, AccountParameters parameters); + ShapedEntity GetAccountByOwner(Guid ownerId, Guid id, string? fields); + Account? GetAccountByOwner(Guid ownerId, Guid id); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Contracts/ILoggerManager.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Contracts/ILoggerManager.cs new file mode 100644 index 000000000..42d30745d --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Contracts/ILoggerManager.cs @@ -0,0 +1,9 @@ +namespace Contracts; + +public interface ILoggerManager +{ + void LogInfo(string message); + void LogWarn(string message); + void LogDebug(string message); + void LogError(string message); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Contracts/IOwnerRepository.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Contracts/IOwnerRepository.cs new file mode 100644 index 000000000..8089c0a0e --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Contracts/IOwnerRepository.cs @@ -0,0 +1,16 @@ +using Entities.Helpers; +using Entities.Models; +using System; +using System.Threading.Tasks; + +namespace Contracts; + +public interface IOwnerRepository : IRepositoryBase +{ + Task> GetOwners(OwnerParameters ownerParameters); + ShapedEntity GetOwnerById(Guid ownerId, string? fields); + Owner GetOwnerById(Guid ownerId); + void CreateOwner(Owner owner); + void UpdateOwner(Owner dbOwner, Owner owner); + void DeleteOwner(Owner owner); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Contracts/IRepositoryBase.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Contracts/IRepositoryBase.cs new file mode 100644 index 000000000..51283872d --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Contracts/IRepositoryBase.cs @@ -0,0 +1,14 @@ +using System; +using System.Linq; +using System.Linq.Expressions; + +namespace Contracts; + +public interface IRepositoryBase +{ + IQueryable FindAll(); + IQueryable FindByCondition(Expression> expression); + void Create(T entity); + void Update(T entity); + void Delete(T entity); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Contracts/IRepositoryWrapper.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Contracts/IRepositoryWrapper.cs new file mode 100644 index 000000000..b7dd600fe --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Contracts/IRepositoryWrapper.cs @@ -0,0 +1,8 @@ +namespace Contracts; + +public interface IRepositoryWrapper +{ + IOwnerRepository Owner { get; } + IAccountRepository Account { get; } + void Save(); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Entities.csproj b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Entities.csproj new file mode 100644 index 000000000..4aa0cf347 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Entities.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + + + + + + + + diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Enumerations/AccountType.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Enumerations/AccountType.cs new file mode 100644 index 000000000..b84e0ad48 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Enumerations/AccountType.cs @@ -0,0 +1,8 @@ +namespace Entities.Enumerations; + +public enum AccountType +{ + Domestic, + Savings, + Foreign +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/ExtendedModels/OwnerExtended.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/ExtendedModels/OwnerExtended.cs new file mode 100644 index 000000000..b42caa4c4 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/ExtendedModels/OwnerExtended.cs @@ -0,0 +1,27 @@ +using Entities.Models; +using System; +using System.Collections.Generic; + +namespace Entities.ExtendedModels; + +public class OwnerExtended : IEntity +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public DateTime DateOfBirth { get; set; } + public string Address { get; set; } = string.Empty; + + public IEnumerable Accounts { get; set; } = []; + + public OwnerExtended() + { + } + + public OwnerExtended(Owner owner) + { + Id = owner.Id; + Name = owner.Name; + DateOfBirth = owner.DateOfBirth; + Address = owner.Address; + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Extensions/IEntityExtensions.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Extensions/IEntityExtensions.cs new file mode 100644 index 000000000..243eafc70 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Extensions/IEntityExtensions.cs @@ -0,0 +1,10 @@ +using System; + +namespace Entities.Extensions; + +public static class IEntityExtensions +{ + public static bool IsObjectNull(this IEntity? entity) => entity is null; + + public static bool IsEmptyObject(this IEntity entity) => entity.Id.Equals(Guid.Empty); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Extensions/OwnerExtensions.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Extensions/OwnerExtensions.cs new file mode 100644 index 000000000..ba2768266 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Extensions/OwnerExtensions.cs @@ -0,0 +1,13 @@ +using Entities.Models; + +namespace Entities.Extensions; + +public static class OwnerExtensions +{ + public static void Map(this Owner dbOwner, Owner owner) + { + dbOwner.Name = owner.Name; + dbOwner.Address = owner.Address; + dbOwner.DateOfBirth = owner.DateOfBirth; + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Helpers/DataShaper.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Helpers/DataShaper.cs new file mode 100644 index 000000000..8ce76a426 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Helpers/DataShaper.cs @@ -0,0 +1,66 @@ +using Entities.Models; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace Entities.Helpers; + +public class DataShaper : IDataShaper +{ + private readonly PropertyInfo[] _properties; + + public DataShaper() + { + _properties = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance); + } + + public IEnumerable ShapeData(IEnumerable entities, string? fieldsString) + { + var requiredProperties = GetRequiredProperties(fieldsString); + + return entities.Select(entity => FetchDataForEntity(entity, requiredProperties)).ToList(); + } + + public ShapedEntity ShapeData(T entity, string? fieldsString) + { + var requiredProperties = GetRequiredProperties(fieldsString); + + return FetchDataForEntity(entity, requiredProperties); + } + + private IEnumerable GetRequiredProperties(string? fieldsString) + { + if (string.IsNullOrWhiteSpace(fieldsString)) + return _properties; + + var requiredProperties = new List(); + var fields = fieldsString.Split(',', StringSplitOptions.RemoveEmptyEntries); + + foreach (var field in fields) + { + var property = _properties.FirstOrDefault(pi => + pi.Name.Equals(field.Trim(), StringComparison.OrdinalIgnoreCase)); + + if (property is null) + continue; + + requiredProperties.Add(property); + } + + return requiredProperties; + } + + private static ShapedEntity FetchDataForEntity(T entity, IEnumerable requiredProperties) + { + var shapedObject = new ShapedEntity(); + + foreach (var property in requiredProperties) + shapedObject.Entity.TryAdd(property.Name, property.GetValue(entity)!); + + var idProperty = typeof(T).GetProperty("Id"); + shapedObject.Id = (Guid)idProperty!.GetValue(entity)!; + + return shapedObject; + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Helpers/IDataShaper.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Helpers/IDataShaper.cs new file mode 100644 index 000000000..caf6639c1 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Helpers/IDataShaper.cs @@ -0,0 +1,10 @@ +using Entities.Models; +using System.Collections.Generic; + +namespace Entities.Helpers; + +public interface IDataShaper +{ + IEnumerable ShapeData(IEnumerable entities, string? fieldsString); + ShapedEntity ShapeData(T entity, string? fieldsString); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Helpers/ISortHelper.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Helpers/ISortHelper.cs new file mode 100644 index 000000000..3d867c508 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Helpers/ISortHelper.cs @@ -0,0 +1,8 @@ +using System.Linq; + +namespace Entities.Helpers; + +public interface ISortHelper +{ + IQueryable ApplySort(IQueryable entities, string? orderByQueryString); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Helpers/PagedList.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Helpers/PagedList.cs new file mode 100644 index 000000000..a81287b98 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Helpers/PagedList.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; + +namespace Entities.Helpers; + +public class PagedList : List +{ + public int CurrentPage { get; private set; } + public int TotalPages { get; private set; } + public int PageSize { get; private set; } + public int TotalCount { get; private set; } + + public bool HasPrevious => CurrentPage > 1; + public bool HasNext => CurrentPage < TotalPages; + + public PagedList() + { + } + + public PagedList(List items, int count, int pageNumber, int pageSize) + { + TotalCount = count; + PageSize = pageSize; + CurrentPage = pageNumber; + TotalPages = (int)Math.Ceiling(count / (double)pageSize); + + AddRange(items); + } + + public static async Task> ToPagedListAsync(IQueryable source, int pageNumber, int pageSize) + { + var count = await source.CountAsync(); + var items = await source.Skip((pageNumber - 1) * pageSize).Take(pageSize).ToListAsync(); + + return new PagedList(items, count, pageNumber, pageSize); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Helpers/SortHelper.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Helpers/SortHelper.cs new file mode 100644 index 000000000..705154cbe --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Helpers/SortHelper.cs @@ -0,0 +1,46 @@ +using System; +using System.Linq; +using System.Linq.Dynamic.Core; +using System.Reflection; +using System.Text; + +namespace Entities.Helpers; + +public class SortHelper : ISortHelper +{ + public IQueryable ApplySort(IQueryable entities, string? orderByQueryString) + { + if (string.IsNullOrWhiteSpace(orderByQueryString)) + return entities; + + var orderParams = orderByQueryString.Trim().Split(','); + var propertyInfos = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance); + var orderQueryBuilder = new StringBuilder(); + + foreach (var param in orderParams) + { + if (string.IsNullOrWhiteSpace(param)) + continue; + + var propertyFromQueryName = param.Split(' ')[0]; + var objectProperty = propertyInfos.FirstOrDefault(pi => + pi.Name.Equals(propertyFromQueryName, StringComparison.InvariantCultureIgnoreCase)); + + if (objectProperty is null) + continue; + + var sortingOrder = param.EndsWith(" desc", StringComparison.OrdinalIgnoreCase) + ? "descending" + : "ascending"; + + orderQueryBuilder.Append($"{objectProperty.Name} {sortingOrder}, "); + } + + var orderQuery = orderQueryBuilder.ToString().TrimEnd(',', ' '); + + if (string.IsNullOrWhiteSpace(orderQuery)) + return entities; + + return entities.OrderBy(orderQuery); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/IEntity.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/IEntity.cs new file mode 100644 index 000000000..825a8525b --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/IEntity.cs @@ -0,0 +1,8 @@ +using System; + +namespace Entities; + +public interface IEntity +{ + Guid Id { get; set; } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Models/Account.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Models/Account.cs new file mode 100644 index 000000000..7a5590d9f --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Models/Account.cs @@ -0,0 +1,22 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Entities.Models; + +[Table("account")] +public class Account : IEntity +{ + [Key] + [Column("AccountId")] + public Guid Id { get; set; } + + [Required(ErrorMessage = "Date created is required")] + public DateTime DateCreated { get; set; } + + [Required(ErrorMessage = "Account type is required")] + public string AccountType { get; set; } = string.Empty; + + [Required(ErrorMessage = "Owner Id is required")] + public Guid OwnerId { get; set; } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Models/AccountParameters.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Models/AccountParameters.cs new file mode 100644 index 000000000..448b50178 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Models/AccountParameters.cs @@ -0,0 +1,9 @@ +namespace Entities.Models; + +public class AccountParameters : QueryStringParameters +{ + public AccountParameters() + { + OrderBy = "DateCreated"; + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Models/Entity.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Models/Entity.cs new file mode 100644 index 000000000..53e8f469e --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Models/Entity.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Dynamic; +using System.Xml; +using System.Xml.Schema; +using System.Xml.Serialization; + +namespace Entities.Models; + +public class Entity : DynamicObject, IXmlSerializable, IDictionary +{ + private readonly string _root = "EntityWithLinks"; + private readonly IDictionary _expando; + + public Entity() + { + _expando = new ExpandoObject()!; + } + + public override bool TryGetMember(GetMemberBinder binder, out object? result) + { + if (_expando.TryGetValue(binder.Name, out var value)) + { + result = value; + return true; + } + + return base.TryGetMember(binder, out result); + } + + public override bool TrySetMember(SetMemberBinder binder, object? value) + { + _expando[binder.Name] = value!; + + return true; + } + + public XmlSchema? GetSchema() => null; + + public void ReadXml(XmlReader reader) + { + reader.ReadStartElement(_root); + + while (!reader.Name.Equals(_root)) + { + var name = reader.Name; + + reader.MoveToAttribute("type"); + var typeContent = reader.ReadContentAsString(); + var underlyingType = Type.GetType(typeContent); + reader.MoveToContent(); + _expando[name] = reader.ReadElementContentAs(underlyingType!, null!)!; + } + } + + public void WriteXml(XmlWriter writer) + { + foreach (var key in _expando.Keys) + WriteXmlElement(key, _expando[key], writer); + } + + private void WriteXmlElement(string key, object value, XmlWriter writer) + { + writer.WriteStartElement(key); + + if (value is List links) + { + foreach (var link in links) + { + writer.WriteStartElement(nameof(Link)); + WriteXmlElement(nameof(link.Href), link.Href, writer); + WriteXmlElement(nameof(link.Method), link.Method, writer); + WriteXmlElement(nameof(link.Rel), link.Rel, writer); + writer.WriteEndElement(); + } + } + else + { + writer.WriteString(value.ToString()); + } + + writer.WriteEndElement(); + } + + public void Add(string key, object value) => _expando.Add(key, value); + + public bool ContainsKey(string key) => _expando.ContainsKey(key); + + public ICollection Keys => _expando.Keys; + + public bool Remove(string key) => _expando.Remove(key); + + public bool TryGetValue(string key, out object value) => _expando.TryGetValue(key, out value!); + + public ICollection Values => _expando.Values; + + public object this[string key] + { + get => _expando[key]; + set => _expando[key] = value; + } + + public void Add(KeyValuePair item) => _expando.Add(item); + + public void Clear() => _expando.Clear(); + + public bool Contains(KeyValuePair item) => _expando.Contains(item); + + public void CopyTo(KeyValuePair[] array, int arrayIndex) => _expando.CopyTo(array, arrayIndex); + + public int Count => _expando.Count; + + public bool IsReadOnly => _expando.IsReadOnly; + + public bool Remove(KeyValuePair item) => _expando.Remove(item); + + public IEnumerator> GetEnumerator() => _expando.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Models/Link.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Models/Link.cs new file mode 100644 index 000000000..49cdde4d5 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Models/Link.cs @@ -0,0 +1,19 @@ +namespace Entities.Models; + +public class Link +{ + public string Href { get; set; } = string.Empty; + public string Rel { get; set; } = string.Empty; + public string Method { get; set; } = string.Empty; + + public Link() + { + } + + public Link(string href, string rel, string method) + { + Href = href; + Rel = rel; + Method = method; + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Models/LinkCollectionWrapper.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Models/LinkCollectionWrapper.cs new file mode 100644 index 000000000..ba63aea30 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Models/LinkCollectionWrapper.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; + +namespace Entities.Models; + +public class LinkCollectionWrapper : LinkResourceBase +{ + public List Value { get; set; } = []; + + public LinkCollectionWrapper() + { + } + + public LinkCollectionWrapper(List value) + { + Value = value; + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Models/LinkResourceBase.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Models/LinkResourceBase.cs new file mode 100644 index 000000000..649695d61 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Models/LinkResourceBase.cs @@ -0,0 +1,8 @@ +using System.Collections.Generic; + +namespace Entities.Models; + +public class LinkResourceBase +{ + public List Links { get; set; } = []; +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Models/Owner.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Models/Owner.cs new file mode 100644 index 000000000..05b422d00 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Models/Owner.cs @@ -0,0 +1,24 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Entities.Models; + +[Table("owner")] +public class Owner : IEntity +{ + [Key] + [Column("OwnerId")] + public Guid Id { get; set; } + + [Required(ErrorMessage = "Name is required")] + [StringLength(60, ErrorMessage = "Name can't be longer than 60 characters")] + public string Name { get; set; } = string.Empty; + + [Required(ErrorMessage = "Date of birth is required")] + public DateTime DateOfBirth { get; set; } + + [Required(ErrorMessage = "Address is required")] + [StringLength(100, ErrorMessage = "Address can't be longer than 100 characters")] + public string Address { get; set; } = string.Empty; +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Models/OwnerParameters.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Models/OwnerParameters.cs new file mode 100644 index 000000000..55d4cbe80 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Models/OwnerParameters.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; + +namespace Entities.Models; + +public class OwnerParameters : QueryStringParameters +{ + public OwnerParameters() + { + OrderBy = "name"; + } + + [Range(1900, 2100)] + public int? MinYearOfBirth { get; set; } + + [Range(1900, 2100)] + public int? MaxYearOfBirth { get; set; } + + public bool ValidYearRange => + MinYearOfBirth is null || MaxYearOfBirth is null || MaxYearOfBirth >= MinYearOfBirth; + + public string? SearchTerm { get; set; } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Models/QueryStringParameters.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Models/QueryStringParameters.cs new file mode 100644 index 000000000..da32d2c6a --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Models/QueryStringParameters.cs @@ -0,0 +1,27 @@ +using System; + +namespace Entities.Models; + +public abstract class QueryStringParameters +{ + const int MaxPageSize = 50; + + private int _pageNumber = 1; + private int _pageSize = 10; + + public int PageNumber + { + get => _pageNumber; + set => _pageNumber = Math.Max(value, 1); + } + + public int PageSize + { + get => _pageSize; + set => _pageSize = Math.Clamp(value, 1, MaxPageSize); + } + + public string? OrderBy { get; set; } + + public string? Fields { get; set; } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Models/ShapedEntity.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Models/ShapedEntity.cs new file mode 100644 index 000000000..b0bfd547a --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/Models/ShapedEntity.cs @@ -0,0 +1,14 @@ +using System; + +namespace Entities.Models; + +public class ShapedEntity +{ + public ShapedEntity() + { + Entity = new Entity(); + } + + public Guid Id { get; set; } + public Entity Entity { get; set; } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/RepositoryContext.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/RepositoryContext.cs new file mode 100644 index 000000000..1857e8e5f --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Entities/RepositoryContext.cs @@ -0,0 +1,15 @@ +using Entities.Models; +using Microsoft.EntityFrameworkCore; + +namespace Entities; + +public class RepositoryContext : DbContext +{ + public RepositoryContext(DbContextOptions options) + : base(options) + { + } + + public DbSet Owners { get; set; } = null!; + public DbSet Accounts { get; set; } = null!; +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/LoggerService/LoggerManager.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/LoggerService/LoggerManager.cs new file mode 100644 index 000000000..dd71d82d6 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/LoggerService/LoggerManager.cs @@ -0,0 +1,17 @@ +using Contracts; +using NLog; + +namespace LoggerService; + +public class LoggerManager : ILoggerManager +{ + private static readonly ILogger Logger = LogManager.GetCurrentClassLogger(); + + public void LogDebug(string message) => Logger.Debug(message); + + public void LogError(string message) => Logger.Error(message); + + public void LogInfo(string message) => Logger.Info(message); + + public void LogWarn(string message) => Logger.Warn(message); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/LoggerService/LoggerService.csproj b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/LoggerService/LoggerService.csproj new file mode 100644 index 000000000..8adf826f0 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/LoggerService/LoggerService.csproj @@ -0,0 +1,16 @@ + + + + net10.0 + enable + + + + + + + + + + + diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Repository/AccountRepository.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Repository/AccountRepository.cs new file mode 100644 index 000000000..b4e8f023c --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Repository/AccountRepository.cs @@ -0,0 +1,48 @@ +using Contracts; +using Entities; +using Entities.Helpers; +using Entities.Models; +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace Repository; + +public class AccountRepository : RepositoryBase, IAccountRepository +{ + private readonly ISortHelper _sortHelper; + private readonly IDataShaper _dataShaper; + + public AccountRepository(RepositoryContext repositoryContext, + ISortHelper sortHelper, + IDataShaper dataShaper) + : base(repositoryContext) + { + _sortHelper = sortHelper; + _dataShaper = dataShaper; + } + + public async Task> GetAccountsByOwner(Guid ownerId, AccountParameters parameters) + { + var accounts = FindByCondition(a => a.OwnerId.Equals(ownerId)); + + var sortedAccounts = _sortHelper.ApplySort(accounts.OrderBy(a => a.DateCreated), parameters.OrderBy); + + var pagedAccounts = await PagedList.ToPagedListAsync(sortedAccounts, + parameters.PageNumber, + parameters.PageSize); + + var shapedAccounts = _dataShaper.ShapeData(pagedAccounts, parameters.Fields).ToList(); + + return new PagedList(shapedAccounts, + pagedAccounts.TotalCount, + pagedAccounts.CurrentPage, + pagedAccounts.PageSize); + } + + public ShapedEntity GetAccountByOwner(Guid ownerId, Guid id, string? fields) => + _dataShaper.ShapeData(GetAccountByOwner(ownerId, id) ?? new Account(), fields); + + public Account? GetAccountByOwner(Guid ownerId, Guid id) => + FindByCondition(a => a.OwnerId.Equals(ownerId) && a.Id.Equals(id)).SingleOrDefault(); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Repository/Extensions/RepositoryOwnerExtensions.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Repository/Extensions/RepositoryOwnerExtensions.cs new file mode 100644 index 000000000..abf4cd02e --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Repository/Extensions/RepositoryOwnerExtensions.cs @@ -0,0 +1,17 @@ +using Entities.Models; +using System.Linq; + +namespace Repository.Extensions; + +public static class RepositoryOwnerExtensions +{ + public static IQueryable Search(this IQueryable owners, string? searchTerm) + { + if (string.IsNullOrWhiteSpace(searchTerm)) + return owners; + + var term = searchTerm.Trim(); + + return owners.Where(o => o.Name.Contains(term) || o.Address.Contains(term)); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Repository/OwnerRepository.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Repository/OwnerRepository.cs new file mode 100644 index 000000000..2d21d13d7 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Repository/OwnerRepository.cs @@ -0,0 +1,68 @@ +using Contracts; +using Entities; +using Entities.Extensions; +using Entities.Helpers; +using Entities.Models; +using Repository.Extensions; +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace Repository; + +public class OwnerRepository : RepositoryBase, IOwnerRepository +{ + private readonly ISortHelper _sortHelper; + private readonly IDataShaper _dataShaper; + + public OwnerRepository(RepositoryContext repositoryContext, + ISortHelper sortHelper, + IDataShaper dataShaper) + : base(repositoryContext) + { + _sortHelper = sortHelper; + _dataShaper = dataShaper; + } + + public async Task> GetOwners(OwnerParameters ownerParameters) + { + var owners = FindAll(); + + if (ownerParameters.MinYearOfBirth is { } minYear) + owners = owners.Where(o => o.DateOfBirth >= new DateTime(minYear, 1, 1)); + + if (ownerParameters.MaxYearOfBirth is { } maxYear) + owners = owners.Where(o => o.DateOfBirth < new DateTime(maxYear + 1, 1, 1)); + + owners = owners.Search(ownerParameters.SearchTerm); + + var sortedOwners = _sortHelper.ApplySort(owners.OrderBy(o => o.Name), ownerParameters.OrderBy); + + var pagedOwners = await PagedList.ToPagedListAsync(sortedOwners, + ownerParameters.PageNumber, + ownerParameters.PageSize); + + var shapedOwners = _dataShaper.ShapeData(pagedOwners, ownerParameters.Fields).ToList(); + + return new PagedList(shapedOwners, + pagedOwners.TotalCount, + pagedOwners.CurrentPage, + pagedOwners.PageSize); + } + + public ShapedEntity GetOwnerById(Guid ownerId, string? fields) => + _dataShaper.ShapeData(GetOwnerById(ownerId), fields); + + public Owner GetOwnerById(Guid ownerId) => + FindByCondition(owner => owner.Id.Equals(ownerId)).SingleOrDefault() ?? new Owner(); + + public void CreateOwner(Owner owner) => Create(owner); + + public void UpdateOwner(Owner dbOwner, Owner owner) + { + dbOwner.Map(owner); + Update(dbOwner); + } + + public void DeleteOwner(Owner owner) => Delete(owner); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Repository/Repository.csproj b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Repository/Repository.csproj new file mode 100644 index 000000000..61a67779d --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Repository/Repository.csproj @@ -0,0 +1,16 @@ + + + + net10.0 + enable + + + + + + + + + + + diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Repository/RepositoryBase.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Repository/RepositoryBase.cs new file mode 100644 index 000000000..51053f279 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Repository/RepositoryBase.cs @@ -0,0 +1,33 @@ +using Contracts; +using Entities; +using Microsoft.EntityFrameworkCore; +using System; +using System.Linq; +using System.Linq.Expressions; + +namespace Repository; + +public abstract class RepositoryBase : IRepositoryBase where T : class +{ + protected RepositoryContext RepositoryContext { get; set; } + + protected RepositoryBase(RepositoryContext repositoryContext) + { + RepositoryContext = repositoryContext; + } + + public IQueryable FindAll() => + RepositoryContext.Set() + .AsNoTracking(); + + public IQueryable FindByCondition(Expression> expression) => + RepositoryContext.Set() + .Where(expression) + .AsNoTracking(); + + public void Create(T entity) => RepositoryContext.Set().Add(entity); + + public void Update(T entity) => RepositoryContext.Set().Update(entity); + + public void Delete(T entity) => RepositoryContext.Set().Remove(entity); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Repository/RepositoryWrapper.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Repository/RepositoryWrapper.cs new file mode 100644 index 000000000..deedb52ed --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/Repository/RepositoryWrapper.cs @@ -0,0 +1,36 @@ +using Contracts; +using Entities; +using Entities.Helpers; +using Entities.Models; + +namespace Repository; + +public class RepositoryWrapper : IRepositoryWrapper +{ + private readonly RepositoryContext _repoContext; + private readonly ISortHelper _ownerSortHelper; + private readonly ISortHelper _accountSortHelper; + private readonly IDataShaper _ownerDataShaper; + private readonly IDataShaper _accountDataShaper; + private IOwnerRepository? _owner; + private IAccountRepository? _account; + + public RepositoryWrapper(RepositoryContext repositoryContext, + ISortHelper ownerSortHelper, + ISortHelper accountSortHelper, + IDataShaper ownerDataShaper, + IDataShaper accountDataShaper) + { + _repoContext = repositoryContext; + _ownerSortHelper = ownerSortHelper; + _accountSortHelper = accountSortHelper; + _ownerDataShaper = ownerDataShaper; + _accountDataShaper = accountDataShaper; + } + + public IOwnerRepository Owner => _owner ??= new OwnerRepository(_repoContext, _ownerSortHelper, _ownerDataShaper); + + public IAccountRepository Account => _account ??= new AccountRepository(_repoContext, _accountSortHelper, _accountDataShaper); + + public void Save() => _repoContext.SaveChanges(); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/_SqlServer_Init_Script/init.sql b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/_SqlServer_Init_Script/init.sql new file mode 100644 index 000000000..f8232089c --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Hateoas/_SqlServer_Init_Script/init.sql @@ -0,0 +1,44 @@ +-- SQL Server schema and seed data for the Advanced REST Concepts sample. +-- Run it against a database of your choice, then point sqlconnection:connectionString at it. +-- On Windows the default connection string uses LocalDB. Elsewhere, one line gets you a server: +-- docker run -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=Str0ng!Passw0rd" -p 1433:1433 -d mcr.microsoft.com/mssql/server:2022-latest + +IF OBJECT_ID('dbo.account', 'U') IS NOT NULL DROP TABLE dbo.account; +IF OBJECT_ID('dbo.owner', 'U') IS NOT NULL DROP TABLE dbo.owner; + +CREATE TABLE dbo.owner +( + OwnerId uniqueidentifier NOT NULL PRIMARY KEY, + Name nvarchar(60) NOT NULL, + DateOfBirth datetime2 NOT NULL, + Address nvarchar(100) NOT NULL +); + +CREATE TABLE dbo.account +( + AccountId uniqueidentifier NOT NULL PRIMARY KEY, + DateCreated datetime2 NOT NULL, + AccountType nvarchar(45) NOT NULL, + OwnerId uniqueidentifier NOT NULL + CONSTRAINT fk_account_owner REFERENCES dbo.owner (OwnerId) ON UPDATE CASCADE +); + +CREATE INDEX ix_owner_name ON dbo.owner (Name); +CREATE INDEX ix_owner_dateofbirth ON dbo.owner (DateOfBirth); + +INSERT INTO dbo.owner (OwnerId, Name, DateOfBirth, Address) VALUES + ('24fd81f8-d58a-4bcc-9f35-dc6cd5641906', 'John Keen', '1980-12-05', '61 Wellfield Road'), + ('261e1685-cf26-494c-b17c-3546e65f5620', 'Anna Bosh', '1974-11-14', '27 Colored Row'), + ('66774006-2371-4d5b-8518-2177bcf3f73e', 'Nick Somion', '1998-12-15', 'North sunny address 102'), + ('a3c1880c-674c-4d18-8f91-5d3608a2c937', 'Sam Query', '1990-04-22', '91 Western Roads'), + ('f98e4d74-0f68-4aac-89fd-047f1aaca6b6', 'Martin Miller', '1983-05-21', '3 Edgar Buildings'); + +INSERT INTO dbo.account (AccountId, DateCreated, AccountType, OwnerId) VALUES + ('03e91478-5608-4132-a753-d494dafce00b', '2003-12-15', 'Domestic', 'f98e4d74-0f68-4aac-89fd-047f1aaca6b6'), + ('356a5a9b-64bf-4de0-bc84-5395a1fdc9c4', '1996-02-15', 'Domestic', '261e1685-cf26-494c-b17c-3546e65f5620'), + ('371b93f2-f8c5-4a32-894a-fc672741aa5b', '1999-05-04', 'Domestic', '24fd81f8-d58a-4bcc-9f35-dc6cd5641906'), + ('670775db-ecc0-4b90-a9ab-37cd0d8e2801', '1999-12-21', 'Savings', '24fd81f8-d58a-4bcc-9f35-dc6cd5641906'), + ('a3fbad0b-7f48-4feb-8ac0-6d3bbc997bfc', '2010-05-28', 'Domestic', 'a3c1880c-674c-4d18-8f91-5d3608a2c937'), + ('aa15f658-04bb-4f73-82af-82db49d0fbef', '1999-05-12', 'Foreign', '24fd81f8-d58a-4bcc-9f35-dc6cd5641906'), + ('c6066eb0-53ca-43e1-97aa-3c2169eec659', '1996-02-16', 'Foreign', '261e1685-cf26-494c-b17c-3546e65f5620'), + ('eccadf79-85fe-402f-893c-32d3f03ed9b1', '2010-06-20', 'Foreign', 'a3c1880c-674c-4d18-8f91-5d3608a2c937'); diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/AccountOwnerServer.Tests/AccountOwnerServer.Tests.csproj b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/AccountOwnerServer.Tests/AccountOwnerServer.Tests.csproj new file mode 100644 index 000000000..338bac1ce --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/AccountOwnerServer.Tests/AccountOwnerServer.Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + false + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/AccountOwnerServer.Tests/OwnerRepositorySqlServerTests.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/AccountOwnerServer.Tests/OwnerRepositorySqlServerTests.cs new file mode 100644 index 000000000..33c4ff795 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/AccountOwnerServer.Tests/OwnerRepositorySqlServerTests.cs @@ -0,0 +1,36 @@ +using Entities.Helpers; +using Entities.Models; +using Repository; +using System; +using System.Linq; +using System.Threading.Tasks; +using Xunit; + +namespace AccountOwnerServer.Tests; + +/// +/// These run against a real SQL Server started by Testcontainers. They are skipped unless +/// CODEMAZE_SQLSERVER_TESTS=1 is set, because the SQL Server image is not guaranteed to be +/// available on every machine or CI runner. +/// +public class OwnerRepositorySqlServerTests +{ + [SqlServerFact] + public async Task GivenAPageSizeOfTwo_WhenWeAskForTheSecondPage_ThenWeGetTwoOwnersAndTheRightMetadata() + { + await using var sample = await SqlServerSample.StartAsync(); + await using var context = sample.CreateContext(); + + var repository = new OwnerRepository(context); + + var owners = await repository.GetOwners(new OwnerParameters { PageNumber = 2, PageSize = 2 }); + + Assert.Equal(2, owners.Count); + Assert.Equal(5, owners.TotalCount); + Assert.Equal(3, owners.TotalPages); + Assert.True(owners.HasPrevious); + Assert.True(owners.HasNext); + } + + private static string NameOf(Owner owner) => owner.Name; +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/AccountOwnerServer.Tests/QueryStringParametersTests.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/AccountOwnerServer.Tests/QueryStringParametersTests.cs new file mode 100644 index 000000000..0b3791e6f --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/AccountOwnerServer.Tests/QueryStringParametersTests.cs @@ -0,0 +1,39 @@ +using Entities.Models; +using Xunit; + +namespace AccountOwnerServer.Tests; + +public class QueryStringParametersTests +{ + [Theory] + [InlineData(0, 1)] + [InlineData(-3, 1)] + [InlineData(2, 2)] + public void GivenAPageNumber_WhenItIsSet_ThenItNeverDropsBelowOne(int requested, int expected) + { + var parameters = new OwnerParameters { PageNumber = requested }; + + Assert.Equal(expected, parameters.PageNumber); + } + + [Theory] + [InlineData(0, 1)] + [InlineData(-10, 1)] + [InlineData(7, 7)] + [InlineData(500, 50)] + public void GivenAPageSize_WhenItIsSet_ThenItIsClampedBetweenOneAndFifty(int requested, int expected) + { + var parameters = new OwnerParameters { PageSize = requested }; + + Assert.Equal(expected, parameters.PageSize); + } + + [Fact] + public void GivenNoQueryString_WhenParametersAreCreated_ThenTheDefaultsAreOneAndTen() + { + var parameters = new OwnerParameters(); + + Assert.Equal(1, parameters.PageNumber); + Assert.Equal(10, parameters.PageSize); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/AccountOwnerServer.Tests/SqlServerFactAttribute.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/AccountOwnerServer.Tests/SqlServerFactAttribute.cs new file mode 100644 index 000000000..6ae7fafae --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/AccountOwnerServer.Tests/SqlServerFactAttribute.cs @@ -0,0 +1,20 @@ +using System; +using Xunit; + +namespace AccountOwnerServer.Tests; + +/// +/// A fact that runs only when SQL Server integration tests are switched on with +/// CODEMAZE_SQLSERVER_TESTS=1. The tests need a working Docker daemon: Testcontainers +/// starts mcr.microsoft.com/mssql/server for them. +/// +public sealed class SqlServerFactAttribute : FactAttribute +{ + public SqlServerFactAttribute() + { + if (Environment.GetEnvironmentVariable("CODEMAZE_SQLSERVER_TESTS") != "1") + { + Skip = "Set CODEMAZE_SQLSERVER_TESTS=1 (and have Docker running) to run the SQL Server tests."; + } + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/AccountOwnerServer.Tests/SqlServerSample.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/AccountOwnerServer.Tests/SqlServerSample.cs new file mode 100644 index 000000000..e557d6730 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/AccountOwnerServer.Tests/SqlServerSample.cs @@ -0,0 +1,73 @@ +using Entities; +using Entities.Models; +using Microsoft.EntityFrameworkCore; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Testcontainers.MsSql; + +namespace AccountOwnerServer.Tests; + +/// +/// A throwaway SQL Server container seeded with the five owners and eight accounts the +/// articles use. Each test owns its own container, so nothing is created when the tests +/// are skipped. +/// +public sealed class SqlServerSample : IAsyncDisposable +{ + private readonly MsSqlContainer _container; + + private SqlServerSample(MsSqlContainer container) + { + _container = container; + } + + public static async Task StartAsync() + { + var container = new MsSqlBuilder("mcr.microsoft.com/mssql/server:2022-latest").Build(); + await container.StartAsync(); + + var sample = new SqlServerSample(container); + + await using var context = sample.CreateContext(); + await context.Database.EnsureCreatedAsync(); + + context.Owners.AddRange(Owners()); + context.Accounts.AddRange(Accounts()); + await context.SaveChangesAsync(); + + return sample; + } + + public RepositoryContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseSqlServer(_container.GetConnectionString()) + .Options; + + return new RepositoryContext(options); + } + + public async ValueTask DisposeAsync() => await _container.DisposeAsync(); + + public static List Owners() => + [ + new() { Id = Guid.Parse("24fd81f8-d58a-4bcc-9f35-dc6cd5641906"), Name = "John Keen", DateOfBirth = new DateTime(1980, 12, 5), Address = "61 Wellfield Road" }, + new() { Id = Guid.Parse("261e1685-cf26-494c-b17c-3546e65f5620"), Name = "Anna Bosh", DateOfBirth = new DateTime(1974, 11, 14), Address = "27 Colored Row" }, + new() { Id = Guid.Parse("66774006-2371-4d5b-8518-2177bcf3f73e"), Name = "Nick Somion", DateOfBirth = new DateTime(1998, 12, 15), Address = "North sunny address 102" }, + new() { Id = Guid.Parse("a3c1880c-674c-4d18-8f91-5d3608a2c937"), Name = "Sam Query", DateOfBirth = new DateTime(1990, 4, 22), Address = "91 Western Roads" }, + new() { Id = Guid.Parse("f98e4d74-0f68-4aac-89fd-047f1aaca6b6"), Name = "Martin Miller", DateOfBirth = new DateTime(1983, 5, 21), Address = "3 Edgar Buildings" } + ]; + + public static List Accounts() => + [ + new() { Id = Guid.Parse("03e91478-5608-4132-a753-d494dafce00b"), DateCreated = new DateTime(2003, 12, 15), AccountType = "Domestic", OwnerId = Guid.Parse("f98e4d74-0f68-4aac-89fd-047f1aaca6b6") }, + new() { Id = Guid.Parse("356a5a9b-64bf-4de0-bc84-5395a1fdc9c4"), DateCreated = new DateTime(1996, 2, 15), AccountType = "Domestic", OwnerId = Guid.Parse("261e1685-cf26-494c-b17c-3546e65f5620") }, + new() { Id = Guid.Parse("371b93f2-f8c5-4a32-894a-fc672741aa5b"), DateCreated = new DateTime(1999, 5, 4), AccountType = "Domestic", OwnerId = Guid.Parse("24fd81f8-d58a-4bcc-9f35-dc6cd5641906") }, + new() { Id = Guid.Parse("670775db-ecc0-4b90-a9ab-37cd0d8e2801"), DateCreated = new DateTime(1999, 12, 21), AccountType = "Savings", OwnerId = Guid.Parse("24fd81f8-d58a-4bcc-9f35-dc6cd5641906") }, + new() { Id = Guid.Parse("a3fbad0b-7f48-4feb-8ac0-6d3bbc997bfc"), DateCreated = new DateTime(2010, 5, 28), AccountType = "Domestic", OwnerId = Guid.Parse("a3c1880c-674c-4d18-8f91-5d3608a2c937") }, + new() { Id = Guid.Parse("aa15f658-04bb-4f73-82af-82db49d0fbef"), DateCreated = new DateTime(1999, 5, 12), AccountType = "Foreign", OwnerId = Guid.Parse("24fd81f8-d58a-4bcc-9f35-dc6cd5641906") }, + new() { Id = Guid.Parse("c6066eb0-53ca-43e1-97aa-3c2169eec659"), DateCreated = new DateTime(1996, 2, 16), AccountType = "Foreign", OwnerId = Guid.Parse("261e1685-cf26-494c-b17c-3546e65f5620") }, + new() { Id = Guid.Parse("eccadf79-85fe-402f-893c-32d3f03ed9b1"), DateCreated = new DateTime(2010, 6, 20), AccountType = "Foreign", OwnerId = Guid.Parse("a3c1880c-674c-4d18-8f91-5d3608a2c937") } + ]; +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/AccountOwnerServer.sln b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/AccountOwnerServer.sln new file mode 100644 index 000000000..71a558230 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/AccountOwnerServer.sln @@ -0,0 +1,104 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AccountOwnerServer", "AccountOwnerServer\AccountOwnerServer.csproj", "{0012D23A-ED62-495C-AA74-AB4BFFE39CAE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LoggerService", "LoggerService\LoggerService.csproj", "{03084270-2CE5-4FF3-88E5-34757A6D8C7E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contracts", "Contracts\Contracts.csproj", "{DAADC44E-9551-4E5A-A420-8479589EE42D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Entities", "Entities\Entities.csproj", "{44456CA1-A660-41A3-BCA7-509CE7ED41E7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Repository", "Repository\Repository.csproj", "{3673F3A0-5421-485A-BD73-B4E01A37958E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AccountOwnerServer.Tests", "AccountOwnerServer.Tests\AccountOwnerServer.Tests.csproj", "{0149FCAF-D52A-41C7-9583-E42DAA7A40FA}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {0012D23A-ED62-495C-AA74-AB4BFFE39CAE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0012D23A-ED62-495C-AA74-AB4BFFE39CAE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0012D23A-ED62-495C-AA74-AB4BFFE39CAE}.Debug|x64.ActiveCfg = Debug|Any CPU + {0012D23A-ED62-495C-AA74-AB4BFFE39CAE}.Debug|x64.Build.0 = Debug|Any CPU + {0012D23A-ED62-495C-AA74-AB4BFFE39CAE}.Debug|x86.ActiveCfg = Debug|Any CPU + {0012D23A-ED62-495C-AA74-AB4BFFE39CAE}.Debug|x86.Build.0 = Debug|Any CPU + {0012D23A-ED62-495C-AA74-AB4BFFE39CAE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0012D23A-ED62-495C-AA74-AB4BFFE39CAE}.Release|Any CPU.Build.0 = Release|Any CPU + {0012D23A-ED62-495C-AA74-AB4BFFE39CAE}.Release|x64.ActiveCfg = Release|Any CPU + {0012D23A-ED62-495C-AA74-AB4BFFE39CAE}.Release|x64.Build.0 = Release|Any CPU + {0012D23A-ED62-495C-AA74-AB4BFFE39CAE}.Release|x86.ActiveCfg = Release|Any CPU + {0012D23A-ED62-495C-AA74-AB4BFFE39CAE}.Release|x86.Build.0 = Release|Any CPU + {03084270-2CE5-4FF3-88E5-34757A6D8C7E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {03084270-2CE5-4FF3-88E5-34757A6D8C7E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {03084270-2CE5-4FF3-88E5-34757A6D8C7E}.Debug|x64.ActiveCfg = Debug|Any CPU + {03084270-2CE5-4FF3-88E5-34757A6D8C7E}.Debug|x64.Build.0 = Debug|Any CPU + {03084270-2CE5-4FF3-88E5-34757A6D8C7E}.Debug|x86.ActiveCfg = Debug|Any CPU + {03084270-2CE5-4FF3-88E5-34757A6D8C7E}.Debug|x86.Build.0 = Debug|Any CPU + {03084270-2CE5-4FF3-88E5-34757A6D8C7E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {03084270-2CE5-4FF3-88E5-34757A6D8C7E}.Release|Any CPU.Build.0 = Release|Any CPU + {03084270-2CE5-4FF3-88E5-34757A6D8C7E}.Release|x64.ActiveCfg = Release|Any CPU + {03084270-2CE5-4FF3-88E5-34757A6D8C7E}.Release|x64.Build.0 = Release|Any CPU + {03084270-2CE5-4FF3-88E5-34757A6D8C7E}.Release|x86.ActiveCfg = Release|Any CPU + {03084270-2CE5-4FF3-88E5-34757A6D8C7E}.Release|x86.Build.0 = Release|Any CPU + {DAADC44E-9551-4E5A-A420-8479589EE42D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DAADC44E-9551-4E5A-A420-8479589EE42D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DAADC44E-9551-4E5A-A420-8479589EE42D}.Debug|x64.ActiveCfg = Debug|Any CPU + {DAADC44E-9551-4E5A-A420-8479589EE42D}.Debug|x64.Build.0 = Debug|Any CPU + {DAADC44E-9551-4E5A-A420-8479589EE42D}.Debug|x86.ActiveCfg = Debug|Any CPU + {DAADC44E-9551-4E5A-A420-8479589EE42D}.Debug|x86.Build.0 = Debug|Any CPU + {DAADC44E-9551-4E5A-A420-8479589EE42D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DAADC44E-9551-4E5A-A420-8479589EE42D}.Release|Any CPU.Build.0 = Release|Any CPU + {DAADC44E-9551-4E5A-A420-8479589EE42D}.Release|x64.ActiveCfg = Release|Any CPU + {DAADC44E-9551-4E5A-A420-8479589EE42D}.Release|x64.Build.0 = Release|Any CPU + {DAADC44E-9551-4E5A-A420-8479589EE42D}.Release|x86.ActiveCfg = Release|Any CPU + {DAADC44E-9551-4E5A-A420-8479589EE42D}.Release|x86.Build.0 = Release|Any CPU + {44456CA1-A660-41A3-BCA7-509CE7ED41E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {44456CA1-A660-41A3-BCA7-509CE7ED41E7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {44456CA1-A660-41A3-BCA7-509CE7ED41E7}.Debug|x64.ActiveCfg = Debug|Any CPU + {44456CA1-A660-41A3-BCA7-509CE7ED41E7}.Debug|x64.Build.0 = Debug|Any CPU + {44456CA1-A660-41A3-BCA7-509CE7ED41E7}.Debug|x86.ActiveCfg = Debug|Any CPU + {44456CA1-A660-41A3-BCA7-509CE7ED41E7}.Debug|x86.Build.0 = Debug|Any CPU + {44456CA1-A660-41A3-BCA7-509CE7ED41E7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {44456CA1-A660-41A3-BCA7-509CE7ED41E7}.Release|Any CPU.Build.0 = Release|Any CPU + {44456CA1-A660-41A3-BCA7-509CE7ED41E7}.Release|x64.ActiveCfg = Release|Any CPU + {44456CA1-A660-41A3-BCA7-509CE7ED41E7}.Release|x64.Build.0 = Release|Any CPU + {44456CA1-A660-41A3-BCA7-509CE7ED41E7}.Release|x86.ActiveCfg = Release|Any CPU + {44456CA1-A660-41A3-BCA7-509CE7ED41E7}.Release|x86.Build.0 = Release|Any CPU + {3673F3A0-5421-485A-BD73-B4E01A37958E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3673F3A0-5421-485A-BD73-B4E01A37958E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3673F3A0-5421-485A-BD73-B4E01A37958E}.Debug|x64.ActiveCfg = Debug|Any CPU + {3673F3A0-5421-485A-BD73-B4E01A37958E}.Debug|x64.Build.0 = Debug|Any CPU + {3673F3A0-5421-485A-BD73-B4E01A37958E}.Debug|x86.ActiveCfg = Debug|Any CPU + {3673F3A0-5421-485A-BD73-B4E01A37958E}.Debug|x86.Build.0 = Debug|Any CPU + {3673F3A0-5421-485A-BD73-B4E01A37958E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3673F3A0-5421-485A-BD73-B4E01A37958E}.Release|Any CPU.Build.0 = Release|Any CPU + {3673F3A0-5421-485A-BD73-B4E01A37958E}.Release|x64.ActiveCfg = Release|Any CPU + {3673F3A0-5421-485A-BD73-B4E01A37958E}.Release|x64.Build.0 = Release|Any CPU + {3673F3A0-5421-485A-BD73-B4E01A37958E}.Release|x86.ActiveCfg = Release|Any CPU + {3673F3A0-5421-485A-BD73-B4E01A37958E}.Release|x86.Build.0 = Release|Any CPU + {0149FCAF-D52A-41C7-9583-E42DAA7A40FA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0149FCAF-D52A-41C7-9583-E42DAA7A40FA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0149FCAF-D52A-41C7-9583-E42DAA7A40FA}.Debug|x64.ActiveCfg = Debug|Any CPU + {0149FCAF-D52A-41C7-9583-E42DAA7A40FA}.Debug|x64.Build.0 = Debug|Any CPU + {0149FCAF-D52A-41C7-9583-E42DAA7A40FA}.Debug|x86.ActiveCfg = Debug|Any CPU + {0149FCAF-D52A-41C7-9583-E42DAA7A40FA}.Debug|x86.Build.0 = Debug|Any CPU + {0149FCAF-D52A-41C7-9583-E42DAA7A40FA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0149FCAF-D52A-41C7-9583-E42DAA7A40FA}.Release|Any CPU.Build.0 = Release|Any CPU + {0149FCAF-D52A-41C7-9583-E42DAA7A40FA}.Release|x64.ActiveCfg = Release|Any CPU + {0149FCAF-D52A-41C7-9583-E42DAA7A40FA}.Release|x64.Build.0 = Release|Any CPU + {0149FCAF-D52A-41C7-9583-E42DAA7A40FA}.Release|x86.ActiveCfg = Release|Any CPU + {0149FCAF-D52A-41C7-9583-E42DAA7A40FA}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/AccountOwnerServer/AccountOwnerServer.csproj b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/AccountOwnerServer/AccountOwnerServer.csproj new file mode 100644 index 000000000..8d9f2e4a6 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/AccountOwnerServer/AccountOwnerServer.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + enable + + + + + + + + + + + + diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/AccountOwnerServer/Controllers/AccountController.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/AccountOwnerServer/Controllers/AccountController.cs new file mode 100644 index 000000000..a3c8e8dc7 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/AccountOwnerServer/Controllers/AccountController.cs @@ -0,0 +1,62 @@ +using Contracts; +using Entities.Extensions; +using Entities.Models; +using Microsoft.AspNetCore.Mvc; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using System; + +namespace AccountOwnerServer.Controllers; + +[Route("api/owners/{ownerId}/accounts")] +[ApiController] +public class AccountController : ControllerBase +{ + private readonly ILoggerManager _logger; + private readonly IRepositoryWrapper _repository; + + public AccountController(ILoggerManager logger, + IRepositoryWrapper repository) + { + _logger = logger; + _repository = repository; + } + + [HttpGet] + public async Task>> GetAccountsForOwner(Guid ownerId, [FromQuery] AccountParameters parameters) + { + var accounts = await _repository.Account.GetAccountsByOwner(ownerId, parameters); + + var metadata = new + { + accounts.TotalCount, + accounts.PageSize, + accounts.CurrentPage, + accounts.TotalPages, + accounts.HasNext, + accounts.HasPrevious + }; + + Response.Headers["X-Pagination"] = JsonSerializer.Serialize(metadata); + + _logger.LogInfo($"Returned {accounts.TotalCount} accounts from database."); + + return Ok(accounts); + } + + [HttpGet("{id}")] + public IActionResult GetAccountForOwner(Guid ownerId, Guid id) + { + var account = _repository.Account.GetAccountByOwner(ownerId, id); + + if (account.IsObjectNull()) + { + _logger.LogError($"Account with id: {id}, hasn't been found in db."); + return NotFound(); + } + + return Ok(account); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/AccountOwnerServer/Controllers/OwnerController.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/AccountOwnerServer/Controllers/OwnerController.cs new file mode 100644 index 000000000..3748694b9 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/AccountOwnerServer/Controllers/OwnerController.cs @@ -0,0 +1,131 @@ +using Contracts; +using Entities.Extensions; +using Entities.Models; +using Microsoft.AspNetCore.Mvc; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using System; + +namespace AccountOwnerServer.Controllers; + +[Route("api/owners")] +[ApiController] +public class OwnerController : ControllerBase +{ + private readonly ILoggerManager _logger; + private readonly IRepositoryWrapper _repository; + + public OwnerController(ILoggerManager logger, + IRepositoryWrapper repository) + { + _logger = logger; + _repository = repository; + } + + [HttpGet] + public async Task>> GetOwners([FromQuery] OwnerParameters ownerParameters) + { + var owners = await _repository.Owner.GetOwners(ownerParameters); + + var metadata = new + { + owners.TotalCount, + owners.PageSize, + owners.CurrentPage, + owners.TotalPages, + owners.HasNext, + owners.HasPrevious + }; + + Response.Headers["X-Pagination"] = JsonSerializer.Serialize(metadata); + + _logger.LogInfo($"Returned {owners.TotalCount} owners from database."); + + return Ok(owners); + } + + [HttpGet("{id}", Name = "OwnerById")] + public IActionResult GetOwnerById(Guid id) + { + var owner = _repository.Owner.GetOwnerById(id); + + if (owner.IsEmptyObject()) + { + _logger.LogError($"Owner with id: {id}, hasn't been found in db."); + return NotFound(); + } + + _logger.LogInfo($"Returned owner with id: {id}"); + + return Ok(owner); + } + + [HttpPost] + public IActionResult CreateOwner([FromBody] Owner owner) + { + if (owner.IsObjectNull()) + { + _logger.LogError("Owner object sent from client is null."); + return BadRequest("Owner object is null"); + } + + if (!ModelState.IsValid) + { + _logger.LogError("Invalid owner object sent from client."); + return BadRequest("Invalid model object"); + } + + _repository.Owner.CreateOwner(owner); + _repository.Save(); + + return CreatedAtRoute("OwnerById", new { id = owner.Id }, owner); + } + + [HttpPut("{id}")] + public IActionResult UpdateOwner(Guid id, [FromBody] Owner owner) + { + if (owner.IsObjectNull()) + { + _logger.LogError("Owner object sent from client is null."); + return BadRequest("Owner object is null"); + } + + if (!ModelState.IsValid) + { + _logger.LogError("Invalid owner object sent from client."); + return BadRequest("Invalid model object"); + } + + var dbOwner = _repository.Owner.GetOwnerById(id); + + if (dbOwner.IsEmptyObject()) + { + _logger.LogError($"Owner with id: {id}, hasn't been found in db."); + return NotFound(); + } + + _repository.Owner.UpdateOwner(dbOwner, owner); + _repository.Save(); + + return NoContent(); + } + + [HttpDelete("{id}")] + public IActionResult DeleteOwner(Guid id) + { + var owner = _repository.Owner.GetOwnerById(id); + + if (owner.IsEmptyObject()) + { + _logger.LogError($"Owner with id: {id}, hasn't been found in db."); + return NotFound(); + } + + _repository.Owner.DeleteOwner(owner); + _repository.Save(); + + return NoContent(); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/AccountOwnerServer/Extensions/ServiceExtensions.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/AccountOwnerServer/Extensions/ServiceExtensions.cs new file mode 100644 index 000000000..3acc356d1 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/AccountOwnerServer/Extensions/ServiceExtensions.cs @@ -0,0 +1,35 @@ +using Contracts; +using Entities; +using LoggerService; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Repository; + +namespace AccountOwnerServer.Extensions; + +public static class ServiceExtensions +{ + public static void ConfigureCors(this IServiceCollection services) => + services.AddCors(options => + options.AddPolicy("CorsPolicy", + builder => builder.WithOrigins("http://localhost:5000", "https://localhost:5001") + .AllowAnyMethod() + .AllowAnyHeader() + .AllowCredentials() + .WithExposedHeaders("X-Pagination"))); + + public static void ConfigureLoggerService(this IServiceCollection services) => + services.AddSingleton(); + + public static void ConfigureSqlContext(this IServiceCollection services, IConfiguration config) + { + var connectionString = config["sqlconnection:connectionString"]; + services.AddDbContext(o => o.UseSqlServer(connectionString)); + } + + public static void ConfigureRepositoryWrapper(this IServiceCollection services) + { + services.AddScoped(); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/AccountOwnerServer/Program.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/AccountOwnerServer/Program.cs new file mode 100644 index 000000000..b45a72e8c --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/AccountOwnerServer/Program.cs @@ -0,0 +1,60 @@ +using AccountOwnerServer.Extensions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using NLog; +using System.Net; + +LogManager.Setup().LoadConfigurationFromFile("nlog.config"); + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.ConfigureCors(); +builder.Services.ConfigureLoggerService(); +builder.Services.ConfigureSqlContext(builder.Configuration); +builder.Services.ConfigureRepositoryWrapper(); + +builder.Services.AddControllers(); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) + app.UseDeveloperExceptionPage(); +else + app.UseHsts(); + +app.UseExceptionHandler(appError => +{ + appError.Run(async context => + { + context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; + context.Response.ContentType = "application/json"; + + var contextFeature = context.Features.Get(); + + if (contextFeature is not null) + { + await context.Response.WriteAsJsonAsync(new + { + context.Response.StatusCode, + Message = "Internal Server Error." + }); + } + }); +}); + +app.UseHttpsRedirection(); + +app.UseCors("CorsPolicy"); + +app.UseForwardedHeaders(new ForwardedHeadersOptions +{ + ForwardedHeaders = ForwardedHeaders.All +}); + +app.MapControllers(); + +app.Run(); diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/AccountOwnerServer/Properties/launchSettings.json b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/AccountOwnerServer/Properties/launchSettings.json new file mode 100644 index 000000000..a82b9223a --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/AccountOwnerServer/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "AccountOwnerServer": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "api/owners", + "applicationUrl": "https://localhost:5001;http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/AccountOwnerServer/appsettings.Development.json b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/AccountOwnerServer/appsettings.Development.json new file mode 100644 index 000000000..e203e9407 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/AccountOwnerServer/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + } + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/AccountOwnerServer/appsettings.json b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/AccountOwnerServer/appsettings.json new file mode 100644 index 000000000..0096cb3d4 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/AccountOwnerServer/appsettings.json @@ -0,0 +1,11 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning" + } + }, + "sqlconnection": { + "connectionString": "Server=(localdb)\\mssqllocaldb;Database=AccountOwner;Trusted_Connection=True;TrustServerCertificate=True;" + }, + "AllowedHosts": "*" +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/AccountOwnerServer/nlog.config b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/AccountOwnerServer/nlog.config new file mode 100644 index 000000000..52f675aa6 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/AccountOwnerServer/nlog.config @@ -0,0 +1,17 @@ + + + + + + + + + + + diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Contracts/Contracts.csproj b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Contracts/Contracts.csproj new file mode 100644 index 000000000..b201bbf27 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Contracts/Contracts.csproj @@ -0,0 +1,12 @@ + + + + net10.0 + enable + + + + + + + diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Contracts/IAccountRepository.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Contracts/IAccountRepository.cs new file mode 100644 index 000000000..9a0730ab7 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Contracts/IAccountRepository.cs @@ -0,0 +1,12 @@ +using Entities.Helpers; +using Entities.Models; +using System; +using System.Threading.Tasks; + +namespace Contracts; + +public interface IAccountRepository : IRepositoryBase +{ + Task> GetAccountsByOwner(Guid ownerId, AccountParameters parameters); + Account? GetAccountByOwner(Guid ownerId, Guid id); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Contracts/ILoggerManager.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Contracts/ILoggerManager.cs new file mode 100644 index 000000000..42d30745d --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Contracts/ILoggerManager.cs @@ -0,0 +1,9 @@ +namespace Contracts; + +public interface ILoggerManager +{ + void LogInfo(string message); + void LogWarn(string message); + void LogDebug(string message); + void LogError(string message); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Contracts/IOwnerRepository.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Contracts/IOwnerRepository.cs new file mode 100644 index 000000000..137669404 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Contracts/IOwnerRepository.cs @@ -0,0 +1,15 @@ +using Entities.Helpers; +using Entities.Models; +using System; +using System.Threading.Tasks; + +namespace Contracts; + +public interface IOwnerRepository : IRepositoryBase +{ + Task> GetOwners(OwnerParameters ownerParameters); + Owner GetOwnerById(Guid ownerId); + void CreateOwner(Owner owner); + void UpdateOwner(Owner dbOwner, Owner owner); + void DeleteOwner(Owner owner); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Contracts/IRepositoryBase.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Contracts/IRepositoryBase.cs new file mode 100644 index 000000000..51283872d --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Contracts/IRepositoryBase.cs @@ -0,0 +1,14 @@ +using System; +using System.Linq; +using System.Linq.Expressions; + +namespace Contracts; + +public interface IRepositoryBase +{ + IQueryable FindAll(); + IQueryable FindByCondition(Expression> expression); + void Create(T entity); + void Update(T entity); + void Delete(T entity); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Contracts/IRepositoryWrapper.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Contracts/IRepositoryWrapper.cs new file mode 100644 index 000000000..b7dd600fe --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Contracts/IRepositoryWrapper.cs @@ -0,0 +1,8 @@ +namespace Contracts; + +public interface IRepositoryWrapper +{ + IOwnerRepository Owner { get; } + IAccountRepository Account { get; } + void Save(); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Entities/Entities.csproj b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Entities/Entities.csproj new file mode 100644 index 000000000..ea034f6d3 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Entities/Entities.csproj @@ -0,0 +1,12 @@ + + + + net10.0 + enable + + + + + + + diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Entities/Enumerations/AccountType.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Entities/Enumerations/AccountType.cs new file mode 100644 index 000000000..b84e0ad48 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Entities/Enumerations/AccountType.cs @@ -0,0 +1,8 @@ +namespace Entities.Enumerations; + +public enum AccountType +{ + Domestic, + Savings, + Foreign +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Entities/Extensions/IEntityExtensions.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Entities/Extensions/IEntityExtensions.cs new file mode 100644 index 000000000..243eafc70 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Entities/Extensions/IEntityExtensions.cs @@ -0,0 +1,10 @@ +using System; + +namespace Entities.Extensions; + +public static class IEntityExtensions +{ + public static bool IsObjectNull(this IEntity? entity) => entity is null; + + public static bool IsEmptyObject(this IEntity entity) => entity.Id.Equals(Guid.Empty); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Entities/Extensions/OwnerExtensions.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Entities/Extensions/OwnerExtensions.cs new file mode 100644 index 000000000..ba2768266 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Entities/Extensions/OwnerExtensions.cs @@ -0,0 +1,13 @@ +using Entities.Models; + +namespace Entities.Extensions; + +public static class OwnerExtensions +{ + public static void Map(this Owner dbOwner, Owner owner) + { + dbOwner.Name = owner.Name; + dbOwner.Address = owner.Address; + dbOwner.DateOfBirth = owner.DateOfBirth; + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Entities/Helpers/PagedList.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Entities/Helpers/PagedList.cs new file mode 100644 index 000000000..a81287b98 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Entities/Helpers/PagedList.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; + +namespace Entities.Helpers; + +public class PagedList : List +{ + public int CurrentPage { get; private set; } + public int TotalPages { get; private set; } + public int PageSize { get; private set; } + public int TotalCount { get; private set; } + + public bool HasPrevious => CurrentPage > 1; + public bool HasNext => CurrentPage < TotalPages; + + public PagedList() + { + } + + public PagedList(List items, int count, int pageNumber, int pageSize) + { + TotalCount = count; + PageSize = pageSize; + CurrentPage = pageNumber; + TotalPages = (int)Math.Ceiling(count / (double)pageSize); + + AddRange(items); + } + + public static async Task> ToPagedListAsync(IQueryable source, int pageNumber, int pageSize) + { + var count = await source.CountAsync(); + var items = await source.Skip((pageNumber - 1) * pageSize).Take(pageSize).ToListAsync(); + + return new PagedList(items, count, pageNumber, pageSize); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Entities/IEntity.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Entities/IEntity.cs new file mode 100644 index 000000000..825a8525b --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Entities/IEntity.cs @@ -0,0 +1,8 @@ +using System; + +namespace Entities; + +public interface IEntity +{ + Guid Id { get; set; } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Entities/Models/Account.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Entities/Models/Account.cs new file mode 100644 index 000000000..7a5590d9f --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Entities/Models/Account.cs @@ -0,0 +1,22 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Entities.Models; + +[Table("account")] +public class Account : IEntity +{ + [Key] + [Column("AccountId")] + public Guid Id { get; set; } + + [Required(ErrorMessage = "Date created is required")] + public DateTime DateCreated { get; set; } + + [Required(ErrorMessage = "Account type is required")] + public string AccountType { get; set; } = string.Empty; + + [Required(ErrorMessage = "Owner Id is required")] + public Guid OwnerId { get; set; } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Entities/Models/AccountParameters.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Entities/Models/AccountParameters.cs new file mode 100644 index 000000000..8da0393ae --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Entities/Models/AccountParameters.cs @@ -0,0 +1,5 @@ +namespace Entities.Models; + +public class AccountParameters : QueryStringParameters +{ +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Entities/Models/Owner.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Entities/Models/Owner.cs new file mode 100644 index 000000000..05b422d00 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Entities/Models/Owner.cs @@ -0,0 +1,24 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Entities.Models; + +[Table("owner")] +public class Owner : IEntity +{ + [Key] + [Column("OwnerId")] + public Guid Id { get; set; } + + [Required(ErrorMessage = "Name is required")] + [StringLength(60, ErrorMessage = "Name can't be longer than 60 characters")] + public string Name { get; set; } = string.Empty; + + [Required(ErrorMessage = "Date of birth is required")] + public DateTime DateOfBirth { get; set; } + + [Required(ErrorMessage = "Address is required")] + [StringLength(100, ErrorMessage = "Address can't be longer than 100 characters")] + public string Address { get; set; } = string.Empty; +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Entities/Models/OwnerParameters.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Entities/Models/OwnerParameters.cs new file mode 100644 index 000000000..4991f6a7c --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Entities/Models/OwnerParameters.cs @@ -0,0 +1,5 @@ +namespace Entities.Models; + +public class OwnerParameters : QueryStringParameters +{ +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Entities/Models/QueryStringParameters.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Entities/Models/QueryStringParameters.cs new file mode 100644 index 000000000..b017779a9 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Entities/Models/QueryStringParameters.cs @@ -0,0 +1,23 @@ +using System; + +namespace Entities.Models; + +public abstract class QueryStringParameters +{ + const int MaxPageSize = 50; + + private int _pageNumber = 1; + private int _pageSize = 10; + + public int PageNumber + { + get => _pageNumber; + set => _pageNumber = Math.Max(value, 1); + } + + public int PageSize + { + get => _pageSize; + set => _pageSize = Math.Clamp(value, 1, MaxPageSize); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Entities/RepositoryContext.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Entities/RepositoryContext.cs new file mode 100644 index 000000000..1857e8e5f --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Entities/RepositoryContext.cs @@ -0,0 +1,15 @@ +using Entities.Models; +using Microsoft.EntityFrameworkCore; + +namespace Entities; + +public class RepositoryContext : DbContext +{ + public RepositoryContext(DbContextOptions options) + : base(options) + { + } + + public DbSet Owners { get; set; } = null!; + public DbSet Accounts { get; set; } = null!; +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/LoggerService/LoggerManager.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/LoggerService/LoggerManager.cs new file mode 100644 index 000000000..dd71d82d6 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/LoggerService/LoggerManager.cs @@ -0,0 +1,17 @@ +using Contracts; +using NLog; + +namespace LoggerService; + +public class LoggerManager : ILoggerManager +{ + private static readonly ILogger Logger = LogManager.GetCurrentClassLogger(); + + public void LogDebug(string message) => Logger.Debug(message); + + public void LogError(string message) => Logger.Error(message); + + public void LogInfo(string message) => Logger.Info(message); + + public void LogWarn(string message) => Logger.Warn(message); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/LoggerService/LoggerService.csproj b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/LoggerService/LoggerService.csproj new file mode 100644 index 000000000..8adf826f0 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/LoggerService/LoggerService.csproj @@ -0,0 +1,16 @@ + + + + net10.0 + enable + + + + + + + + + + + diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Repository/AccountRepository.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Repository/AccountRepository.cs new file mode 100644 index 000000000..888e94bed --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Repository/AccountRepository.cs @@ -0,0 +1,31 @@ +using Contracts; +using Entities; +using Entities.Helpers; +using Entities.Models; +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace Repository; + +public class AccountRepository : RepositoryBase, IAccountRepository +{ + public AccountRepository(RepositoryContext repositoryContext) + : base(repositoryContext) + { + } + + public Task> GetAccountsByOwner(Guid ownerId, AccountParameters parameters) + { + var accounts = FindByCondition(a => a.OwnerId.Equals(ownerId)); + + var sortedAccounts = accounts.OrderBy(a => a.DateCreated); + + return PagedList.ToPagedListAsync(sortedAccounts, + parameters.PageNumber, + parameters.PageSize); + } + + public Account? GetAccountByOwner(Guid ownerId, Guid id) => + FindByCondition(a => a.OwnerId.Equals(ownerId) && a.Id.Equals(id)).SingleOrDefault(); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Repository/OwnerRepository.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Repository/OwnerRepository.cs new file mode 100644 index 000000000..6ae41f068 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Repository/OwnerRepository.cs @@ -0,0 +1,42 @@ +using Contracts; +using Entities; +using Entities.Extensions; +using Entities.Helpers; +using Entities.Models; +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace Repository; + +public class OwnerRepository : RepositoryBase, IOwnerRepository +{ + public OwnerRepository(RepositoryContext repositoryContext) + : base(repositoryContext) + { + } + + public Task> GetOwners(OwnerParameters ownerParameters) + { + var owners = FindAll(); + + var sortedOwners = owners.OrderBy(o => o.Name); + + return PagedList.ToPagedListAsync(sortedOwners, + ownerParameters.PageNumber, + ownerParameters.PageSize); + } + + public Owner GetOwnerById(Guid ownerId) => + FindByCondition(owner => owner.Id.Equals(ownerId)).SingleOrDefault() ?? new Owner(); + + public void CreateOwner(Owner owner) => Create(owner); + + public void UpdateOwner(Owner dbOwner, Owner owner) + { + dbOwner.Map(owner); + Update(dbOwner); + } + + public void DeleteOwner(Owner owner) => Delete(owner); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Repository/Repository.csproj b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Repository/Repository.csproj new file mode 100644 index 000000000..61a67779d --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Repository/Repository.csproj @@ -0,0 +1,16 @@ + + + + net10.0 + enable + + + + + + + + + + + diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Repository/RepositoryBase.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Repository/RepositoryBase.cs new file mode 100644 index 000000000..51053f279 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Repository/RepositoryBase.cs @@ -0,0 +1,33 @@ +using Contracts; +using Entities; +using Microsoft.EntityFrameworkCore; +using System; +using System.Linq; +using System.Linq.Expressions; + +namespace Repository; + +public abstract class RepositoryBase : IRepositoryBase where T : class +{ + protected RepositoryContext RepositoryContext { get; set; } + + protected RepositoryBase(RepositoryContext repositoryContext) + { + RepositoryContext = repositoryContext; + } + + public IQueryable FindAll() => + RepositoryContext.Set() + .AsNoTracking(); + + public IQueryable FindByCondition(Expression> expression) => + RepositoryContext.Set() + .Where(expression) + .AsNoTracking(); + + public void Create(T entity) => RepositoryContext.Set().Add(entity); + + public void Update(T entity) => RepositoryContext.Set().Update(entity); + + public void Delete(T entity) => RepositoryContext.Set().Remove(entity); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Repository/RepositoryWrapper.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Repository/RepositoryWrapper.cs new file mode 100644 index 000000000..653c4a87c --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/Repository/RepositoryWrapper.cs @@ -0,0 +1,22 @@ +using Contracts; +using Entities; + +namespace Repository; + +public class RepositoryWrapper : IRepositoryWrapper +{ + private readonly RepositoryContext _repoContext; + private IOwnerRepository? _owner; + private IAccountRepository? _account; + + public RepositoryWrapper(RepositoryContext repositoryContext) + { + _repoContext = repositoryContext; + } + + public IOwnerRepository Owner => _owner ??= new OwnerRepository(_repoContext); + + public IAccountRepository Account => _account ??= new AccountRepository(_repoContext); + + public void Save() => _repoContext.SaveChanges(); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/_SqlServer_Init_Script/init.sql b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/_SqlServer_Init_Script/init.sql new file mode 100644 index 000000000..f8232089c --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Paging/_SqlServer_Init_Script/init.sql @@ -0,0 +1,44 @@ +-- SQL Server schema and seed data for the Advanced REST Concepts sample. +-- Run it against a database of your choice, then point sqlconnection:connectionString at it. +-- On Windows the default connection string uses LocalDB. Elsewhere, one line gets you a server: +-- docker run -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=Str0ng!Passw0rd" -p 1433:1433 -d mcr.microsoft.com/mssql/server:2022-latest + +IF OBJECT_ID('dbo.account', 'U') IS NOT NULL DROP TABLE dbo.account; +IF OBJECT_ID('dbo.owner', 'U') IS NOT NULL DROP TABLE dbo.owner; + +CREATE TABLE dbo.owner +( + OwnerId uniqueidentifier NOT NULL PRIMARY KEY, + Name nvarchar(60) NOT NULL, + DateOfBirth datetime2 NOT NULL, + Address nvarchar(100) NOT NULL +); + +CREATE TABLE dbo.account +( + AccountId uniqueidentifier NOT NULL PRIMARY KEY, + DateCreated datetime2 NOT NULL, + AccountType nvarchar(45) NOT NULL, + OwnerId uniqueidentifier NOT NULL + CONSTRAINT fk_account_owner REFERENCES dbo.owner (OwnerId) ON UPDATE CASCADE +); + +CREATE INDEX ix_owner_name ON dbo.owner (Name); +CREATE INDEX ix_owner_dateofbirth ON dbo.owner (DateOfBirth); + +INSERT INTO dbo.owner (OwnerId, Name, DateOfBirth, Address) VALUES + ('24fd81f8-d58a-4bcc-9f35-dc6cd5641906', 'John Keen', '1980-12-05', '61 Wellfield Road'), + ('261e1685-cf26-494c-b17c-3546e65f5620', 'Anna Bosh', '1974-11-14', '27 Colored Row'), + ('66774006-2371-4d5b-8518-2177bcf3f73e', 'Nick Somion', '1998-12-15', 'North sunny address 102'), + ('a3c1880c-674c-4d18-8f91-5d3608a2c937', 'Sam Query', '1990-04-22', '91 Western Roads'), + ('f98e4d74-0f68-4aac-89fd-047f1aaca6b6', 'Martin Miller', '1983-05-21', '3 Edgar Buildings'); + +INSERT INTO dbo.account (AccountId, DateCreated, AccountType, OwnerId) VALUES + ('03e91478-5608-4132-a753-d494dafce00b', '2003-12-15', 'Domestic', 'f98e4d74-0f68-4aac-89fd-047f1aaca6b6'), + ('356a5a9b-64bf-4de0-bc84-5395a1fdc9c4', '1996-02-15', 'Domestic', '261e1685-cf26-494c-b17c-3546e65f5620'), + ('371b93f2-f8c5-4a32-894a-fc672741aa5b', '1999-05-04', 'Domestic', '24fd81f8-d58a-4bcc-9f35-dc6cd5641906'), + ('670775db-ecc0-4b90-a9ab-37cd0d8e2801', '1999-12-21', 'Savings', '24fd81f8-d58a-4bcc-9f35-dc6cd5641906'), + ('a3fbad0b-7f48-4feb-8ac0-6d3bbc997bfc', '2010-05-28', 'Domestic', 'a3c1880c-674c-4d18-8f91-5d3608a2c937'), + ('aa15f658-04bb-4f73-82af-82db49d0fbef', '1999-05-12', 'Foreign', '24fd81f8-d58a-4bcc-9f35-dc6cd5641906'), + ('c6066eb0-53ca-43e1-97aa-3c2169eec659', '1996-02-16', 'Foreign', '261e1685-cf26-494c-b17c-3546e65f5620'), + ('eccadf79-85fe-402f-893c-32d3f03ed9b1', '2010-06-20', 'Foreign', 'a3c1880c-674c-4d18-8f91-5d3608a2c937'); diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/README.md b/aspnetcore-webapi/AdvancedRestConceptsSeries/README.md new file mode 100644 index 000000000..5a3db76bd --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/README.md @@ -0,0 +1,61 @@ +## Advanced REST Concepts in ASP.NET Core Web API: the series samples + +This folder holds the code for the whole advanced REST concepts series: paging, +filtering, searching, sorting, data shaping and HATEOAS, built on one +`AccountOwnerServer` Web API. Each article gets its own folder with its own +solution, and each folder picks up where the previous one left off, so you can +start at the beginning or jump straight to the article you are reading. + +| Folder | Article | +| - | - | +| `StartingProject` | The starting point for the whole series: the `AccountOwnerServer` Web API with no paging yet. Clone this one first. | +| `Paging` | [Pagination in ASP.NET Core Web API: How to Implement Paging](https://code-maze.com/paging-aspnet-core-webapi/) | +| `Filtering` | [Filtering Data in ASP.NET Core Web API](https://code-maze.com/filtering-aspnet-core-webapi/) | +| `Searching` | [Searching in ASP.NET Core Web API With Query Parameters](https://code-maze.com/searching-aspnet-core-webapi/) | +| `Sorting` | [Sorting in ASP.NET Core Web API With a Dynamic OrderBy](https://code-maze.com/sorting-aspnet-core-webapi/) | +| `DataShaping` | [Data Shaping in ASP.NET Core Web API](https://code-maze.com/data-shaping-aspnet-core-webapi/) | +| `Hateoas` | [HATEOAS in ASP.NET Core Web API](https://code-maze.com/hateoas-aspnet-core-web-api/) | + +Every folder builds and its tests run on .NET 10: + +``` +cd Paging +dotnet build AccountOwnerServer.sln +dotnet test AccountOwnerServer.sln +``` + +`AdvancedRestConceptsSeries.sln` in this folder is a convenience solution that +contains all seven, so the whole series can be built and tested in one go. + +### Running the application + +The sample uses SQL Server on EF Core 10. On Windows the connection string in +`appsettings.json` points at LocalDB and needs no setup. Anywhere else, one line +gets you a server: + +``` +docker run -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=Str0ng!Passw0rd" -p 1433:1433 -d mcr.microsoft.com/mssql/server:2022-latest +``` + +Then point `sqlconnection:connectionString` at it and run +`_SqlServer_Init_Script/init.sql`, which creates the two tables and seeds the +five owners and eight accounts the articles use in their examples. + +### The tests + +Each article folder carries an `AccountOwnerServer.Tests` project. Most of the +tests need nothing but the assembly under test: the page-size clamp, the year +range validation, the search extension, the sort helper and the data shaper are +all exercised in memory. + +A smaller set of repository tests runs against a real SQL Server that +[Testcontainers](https://dotnet.testcontainers.org/) starts for the duration of +the test. Those need a working Docker daemon, so they are **skipped unless you +opt in**: + +``` +CODEMAZE_SQLSERVER_TESTS=1 dotnet test AccountOwnerServer.sln +``` + +Without the variable they report as skipped and the rest of the suite still +runs, which is what happens on CI. diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer.Tests/AccountOwnerServer.Tests.csproj b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer.Tests/AccountOwnerServer.Tests.csproj new file mode 100644 index 000000000..338bac1ce --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer.Tests/AccountOwnerServer.Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + false + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer.Tests/OwnerParametersTests.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer.Tests/OwnerParametersTests.cs new file mode 100644 index 000000000..4ccdf0adb --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer.Tests/OwnerParametersTests.cs @@ -0,0 +1,41 @@ +using Entities.Models; +using Xunit; + +namespace AccountOwnerServer.Tests; + +public class OwnerParametersTests +{ + [Fact] + public void GivenTheSameMinAndMaxYear_WhenTheRangeIsValidated_ThenItIsAccepted() + { + var parameters = new OwnerParameters { MinYearOfBirth = 1990, MaxYearOfBirth = 1990 }; + + Assert.True(parameters.ValidYearRange); + } + + [Fact] + public void GivenAMaxYearBelowTheMinYear_WhenTheRangeIsValidated_ThenItIsRejected() + { + var parameters = new OwnerParameters { MinYearOfBirth = 1975, MaxYearOfBirth = 1974 }; + + Assert.False(parameters.ValidYearRange); + } + + [Fact] + public void GivenNoYearsAtAll_WhenTheRangeIsValidated_ThenItIsAccepted() + { + var parameters = new OwnerParameters(); + + Assert.Null(parameters.MinYearOfBirth); + Assert.Null(parameters.MaxYearOfBirth); + Assert.True(parameters.ValidYearRange); + } + + [Fact] + public void GivenOnlyAMinYear_WhenTheRangeIsValidated_ThenItIsAccepted() + { + var parameters = new OwnerParameters { MinYearOfBirth = 1975 }; + + Assert.True(parameters.ValidYearRange); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer.Tests/OwnerRepositorySqlServerTests.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer.Tests/OwnerRepositorySqlServerTests.cs new file mode 100644 index 000000000..bbf16c742 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer.Tests/OwnerRepositorySqlServerTests.cs @@ -0,0 +1,81 @@ +using Entities.Helpers; +using Entities.Models; +using Repository; +using System; +using System.Linq; +using System.Threading.Tasks; +using Xunit; + +namespace AccountOwnerServer.Tests; + +/// +/// These run against a real SQL Server started by Testcontainers. They are skipped unless +/// CODEMAZE_SQLSERVER_TESTS=1 is set, because the SQL Server image is not guaranteed to be +/// available on every machine or CI runner. +/// +public class OwnerRepositorySqlServerTests +{ + [SqlServerFact] + public async Task GivenAPageSizeOfTwo_WhenWeAskForTheSecondPage_ThenWeGetTwoOwnersAndTheRightMetadata() + { + await using var sample = await SqlServerSample.StartAsync(); + await using var context = sample.CreateContext(); + + var repository = new OwnerRepository(context); + + var owners = await repository.GetOwners(new OwnerParameters { PageNumber = 2, PageSize = 2 }); + + Assert.Equal(2, owners.Count); + Assert.Equal(5, owners.TotalCount); + Assert.Equal(3, owners.TotalPages); + Assert.True(owners.HasPrevious); + Assert.True(owners.HasNext); + } + + [SqlServerFact] + public async Task GivenTheSameMinAndMaxYear_WhenWeFilter_ThenTheOwnerBornInThatYearComesBack() + { + await using var sample = await SqlServerSample.StartAsync(); + await using var context = sample.CreateContext(); + + var repository = new OwnerRepository(context); + + var owners = await repository.GetOwners(new OwnerParameters + { + MinYearOfBirth = 1990, + MaxYearOfBirth = 1990 + }); + + Assert.Equal(1, owners.TotalCount); + Assert.Equal("Sam Query", NameOf(owners.Single())); + } + + [SqlServerFact] + public async Task GivenOnlyAMinimumYear_WhenWeFilter_ThenTheUpperBoundIsOpen() + { + await using var sample = await SqlServerSample.StartAsync(); + await using var context = sample.CreateContext(); + + var repository = new OwnerRepository(context); + + var owners = await repository.GetOwners(new OwnerParameters { MinYearOfBirth = 1990 }); + + Assert.Equal(2, owners.TotalCount); + } + + [SqlServerFact] + public async Task GivenALowerCaseSearchTerm_WhenWeSearch_ThenTheDefaultCollationStillMatches() + { + await using var sample = await SqlServerSample.StartAsync(); + await using var context = sample.CreateContext(); + + var repository = new OwnerRepository(context); + + var owners = await repository.GetOwners(new OwnerParameters { SearchTerm = "anna" }); + + Assert.Equal(1, owners.TotalCount); + Assert.Equal("Anna Bosh", NameOf(owners.Single())); + } + + private static string NameOf(Owner owner) => owner.Name; +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer.Tests/QueryStringParametersTests.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer.Tests/QueryStringParametersTests.cs new file mode 100644 index 000000000..0b3791e6f --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer.Tests/QueryStringParametersTests.cs @@ -0,0 +1,39 @@ +using Entities.Models; +using Xunit; + +namespace AccountOwnerServer.Tests; + +public class QueryStringParametersTests +{ + [Theory] + [InlineData(0, 1)] + [InlineData(-3, 1)] + [InlineData(2, 2)] + public void GivenAPageNumber_WhenItIsSet_ThenItNeverDropsBelowOne(int requested, int expected) + { + var parameters = new OwnerParameters { PageNumber = requested }; + + Assert.Equal(expected, parameters.PageNumber); + } + + [Theory] + [InlineData(0, 1)] + [InlineData(-10, 1)] + [InlineData(7, 7)] + [InlineData(500, 50)] + public void GivenAPageSize_WhenItIsSet_ThenItIsClampedBetweenOneAndFifty(int requested, int expected) + { + var parameters = new OwnerParameters { PageSize = requested }; + + Assert.Equal(expected, parameters.PageSize); + } + + [Fact] + public void GivenNoQueryString_WhenParametersAreCreated_ThenTheDefaultsAreOneAndTen() + { + var parameters = new OwnerParameters(); + + Assert.Equal(1, parameters.PageNumber); + Assert.Equal(10, parameters.PageSize); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer.Tests/SearchExtensionTests.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer.Tests/SearchExtensionTests.cs new file mode 100644 index 000000000..e83fe53cc --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer.Tests/SearchExtensionTests.cs @@ -0,0 +1,48 @@ +using Entities.Models; +using Repository.Extensions; +using System.Linq; +using Xunit; + +namespace AccountOwnerServer.Tests; + +public class SearchExtensionTests +{ + private static IQueryable Owners() => SqlServerSample.Owners().AsQueryable(); + + [Fact] + public void GivenABlankSearchTerm_WhenWeSearch_ThenTheQueryIsReturnedUntouched() + { + var owners = Owners(); + + Assert.Same(owners, owners.Search(null)); + Assert.Same(owners, owners.Search(string.Empty)); + Assert.Same(owners, owners.Search(" ")); + } + + [Fact] + public void GivenANameFragment_WhenWeSearch_ThenTheMatchingOwnerComesBack() + { + var result = Owners().Search("Anna").ToList(); + + Assert.Single(result); + Assert.Equal("Anna Bosh", result[0].Name); + } + + [Fact] + public void GivenAnAddressFragment_WhenWeSearch_ThenTheColumnIsSearchedToo() + { + var result = Owners().Search("Wellfield").ToList(); + + Assert.Single(result); + Assert.Equal("John Keen", result[0].Name); + } + + [Fact] + public void GivenAPaddedSearchTerm_WhenWeSearch_ThenItIsTrimmedBeforeMatching() + { + var result = Owners().Search(" Somion ").ToList(); + + Assert.Single(result); + Assert.Equal("Nick Somion", result[0].Name); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer.Tests/SqlServerFactAttribute.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer.Tests/SqlServerFactAttribute.cs new file mode 100644 index 000000000..6ae7fafae --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer.Tests/SqlServerFactAttribute.cs @@ -0,0 +1,20 @@ +using System; +using Xunit; + +namespace AccountOwnerServer.Tests; + +/// +/// A fact that runs only when SQL Server integration tests are switched on with +/// CODEMAZE_SQLSERVER_TESTS=1. The tests need a working Docker daemon: Testcontainers +/// starts mcr.microsoft.com/mssql/server for them. +/// +public sealed class SqlServerFactAttribute : FactAttribute +{ + public SqlServerFactAttribute() + { + if (Environment.GetEnvironmentVariable("CODEMAZE_SQLSERVER_TESTS") != "1") + { + Skip = "Set CODEMAZE_SQLSERVER_TESTS=1 (and have Docker running) to run the SQL Server tests."; + } + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer.Tests/SqlServerSample.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer.Tests/SqlServerSample.cs new file mode 100644 index 000000000..e557d6730 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer.Tests/SqlServerSample.cs @@ -0,0 +1,73 @@ +using Entities; +using Entities.Models; +using Microsoft.EntityFrameworkCore; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Testcontainers.MsSql; + +namespace AccountOwnerServer.Tests; + +/// +/// A throwaway SQL Server container seeded with the five owners and eight accounts the +/// articles use. Each test owns its own container, so nothing is created when the tests +/// are skipped. +/// +public sealed class SqlServerSample : IAsyncDisposable +{ + private readonly MsSqlContainer _container; + + private SqlServerSample(MsSqlContainer container) + { + _container = container; + } + + public static async Task StartAsync() + { + var container = new MsSqlBuilder("mcr.microsoft.com/mssql/server:2022-latest").Build(); + await container.StartAsync(); + + var sample = new SqlServerSample(container); + + await using var context = sample.CreateContext(); + await context.Database.EnsureCreatedAsync(); + + context.Owners.AddRange(Owners()); + context.Accounts.AddRange(Accounts()); + await context.SaveChangesAsync(); + + return sample; + } + + public RepositoryContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseSqlServer(_container.GetConnectionString()) + .Options; + + return new RepositoryContext(options); + } + + public async ValueTask DisposeAsync() => await _container.DisposeAsync(); + + public static List Owners() => + [ + new() { Id = Guid.Parse("24fd81f8-d58a-4bcc-9f35-dc6cd5641906"), Name = "John Keen", DateOfBirth = new DateTime(1980, 12, 5), Address = "61 Wellfield Road" }, + new() { Id = Guid.Parse("261e1685-cf26-494c-b17c-3546e65f5620"), Name = "Anna Bosh", DateOfBirth = new DateTime(1974, 11, 14), Address = "27 Colored Row" }, + new() { Id = Guid.Parse("66774006-2371-4d5b-8518-2177bcf3f73e"), Name = "Nick Somion", DateOfBirth = new DateTime(1998, 12, 15), Address = "North sunny address 102" }, + new() { Id = Guid.Parse("a3c1880c-674c-4d18-8f91-5d3608a2c937"), Name = "Sam Query", DateOfBirth = new DateTime(1990, 4, 22), Address = "91 Western Roads" }, + new() { Id = Guid.Parse("f98e4d74-0f68-4aac-89fd-047f1aaca6b6"), Name = "Martin Miller", DateOfBirth = new DateTime(1983, 5, 21), Address = "3 Edgar Buildings" } + ]; + + public static List Accounts() => + [ + new() { Id = Guid.Parse("03e91478-5608-4132-a753-d494dafce00b"), DateCreated = new DateTime(2003, 12, 15), AccountType = "Domestic", OwnerId = Guid.Parse("f98e4d74-0f68-4aac-89fd-047f1aaca6b6") }, + new() { Id = Guid.Parse("356a5a9b-64bf-4de0-bc84-5395a1fdc9c4"), DateCreated = new DateTime(1996, 2, 15), AccountType = "Domestic", OwnerId = Guid.Parse("261e1685-cf26-494c-b17c-3546e65f5620") }, + new() { Id = Guid.Parse("371b93f2-f8c5-4a32-894a-fc672741aa5b"), DateCreated = new DateTime(1999, 5, 4), AccountType = "Domestic", OwnerId = Guid.Parse("24fd81f8-d58a-4bcc-9f35-dc6cd5641906") }, + new() { Id = Guid.Parse("670775db-ecc0-4b90-a9ab-37cd0d8e2801"), DateCreated = new DateTime(1999, 12, 21), AccountType = "Savings", OwnerId = Guid.Parse("24fd81f8-d58a-4bcc-9f35-dc6cd5641906") }, + new() { Id = Guid.Parse("a3fbad0b-7f48-4feb-8ac0-6d3bbc997bfc"), DateCreated = new DateTime(2010, 5, 28), AccountType = "Domestic", OwnerId = Guid.Parse("a3c1880c-674c-4d18-8f91-5d3608a2c937") }, + new() { Id = Guid.Parse("aa15f658-04bb-4f73-82af-82db49d0fbef"), DateCreated = new DateTime(1999, 5, 12), AccountType = "Foreign", OwnerId = Guid.Parse("24fd81f8-d58a-4bcc-9f35-dc6cd5641906") }, + new() { Id = Guid.Parse("c6066eb0-53ca-43e1-97aa-3c2169eec659"), DateCreated = new DateTime(1996, 2, 16), AccountType = "Foreign", OwnerId = Guid.Parse("261e1685-cf26-494c-b17c-3546e65f5620") }, + new() { Id = Guid.Parse("eccadf79-85fe-402f-893c-32d3f03ed9b1"), DateCreated = new DateTime(2010, 6, 20), AccountType = "Foreign", OwnerId = Guid.Parse("a3c1880c-674c-4d18-8f91-5d3608a2c937") } + ]; +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer.sln b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer.sln new file mode 100644 index 000000000..13446cef8 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer.sln @@ -0,0 +1,104 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AccountOwnerServer", "AccountOwnerServer\AccountOwnerServer.csproj", "{92445C79-89E8-4970-9957-B40ADD640C53}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LoggerService", "LoggerService\LoggerService.csproj", "{F8786CC3-520E-4A37-897E-C95951F82815}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contracts", "Contracts\Contracts.csproj", "{753F7805-71E7-4441-AE38-5198F97467DE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Entities", "Entities\Entities.csproj", "{A3159D41-DDBE-4700-B275-3C2B9E1606A9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Repository", "Repository\Repository.csproj", "{5E387D26-B4EE-4206-9538-C3F4F0788985}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AccountOwnerServer.Tests", "AccountOwnerServer.Tests\AccountOwnerServer.Tests.csproj", "{81BD718A-A26A-49E2-9CCB-8B76EA5C56D5}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {92445C79-89E8-4970-9957-B40ADD640C53}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {92445C79-89E8-4970-9957-B40ADD640C53}.Debug|Any CPU.Build.0 = Debug|Any CPU + {92445C79-89E8-4970-9957-B40ADD640C53}.Debug|x64.ActiveCfg = Debug|Any CPU + {92445C79-89E8-4970-9957-B40ADD640C53}.Debug|x64.Build.0 = Debug|Any CPU + {92445C79-89E8-4970-9957-B40ADD640C53}.Debug|x86.ActiveCfg = Debug|Any CPU + {92445C79-89E8-4970-9957-B40ADD640C53}.Debug|x86.Build.0 = Debug|Any CPU + {92445C79-89E8-4970-9957-B40ADD640C53}.Release|Any CPU.ActiveCfg = Release|Any CPU + {92445C79-89E8-4970-9957-B40ADD640C53}.Release|Any CPU.Build.0 = Release|Any CPU + {92445C79-89E8-4970-9957-B40ADD640C53}.Release|x64.ActiveCfg = Release|Any CPU + {92445C79-89E8-4970-9957-B40ADD640C53}.Release|x64.Build.0 = Release|Any CPU + {92445C79-89E8-4970-9957-B40ADD640C53}.Release|x86.ActiveCfg = Release|Any CPU + {92445C79-89E8-4970-9957-B40ADD640C53}.Release|x86.Build.0 = Release|Any CPU + {F8786CC3-520E-4A37-897E-C95951F82815}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F8786CC3-520E-4A37-897E-C95951F82815}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F8786CC3-520E-4A37-897E-C95951F82815}.Debug|x64.ActiveCfg = Debug|Any CPU + {F8786CC3-520E-4A37-897E-C95951F82815}.Debug|x64.Build.0 = Debug|Any CPU + {F8786CC3-520E-4A37-897E-C95951F82815}.Debug|x86.ActiveCfg = Debug|Any CPU + {F8786CC3-520E-4A37-897E-C95951F82815}.Debug|x86.Build.0 = Debug|Any CPU + {F8786CC3-520E-4A37-897E-C95951F82815}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F8786CC3-520E-4A37-897E-C95951F82815}.Release|Any CPU.Build.0 = Release|Any CPU + {F8786CC3-520E-4A37-897E-C95951F82815}.Release|x64.ActiveCfg = Release|Any CPU + {F8786CC3-520E-4A37-897E-C95951F82815}.Release|x64.Build.0 = Release|Any CPU + {F8786CC3-520E-4A37-897E-C95951F82815}.Release|x86.ActiveCfg = Release|Any CPU + {F8786CC3-520E-4A37-897E-C95951F82815}.Release|x86.Build.0 = Release|Any CPU + {753F7805-71E7-4441-AE38-5198F97467DE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {753F7805-71E7-4441-AE38-5198F97467DE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {753F7805-71E7-4441-AE38-5198F97467DE}.Debug|x64.ActiveCfg = Debug|Any CPU + {753F7805-71E7-4441-AE38-5198F97467DE}.Debug|x64.Build.0 = Debug|Any CPU + {753F7805-71E7-4441-AE38-5198F97467DE}.Debug|x86.ActiveCfg = Debug|Any CPU + {753F7805-71E7-4441-AE38-5198F97467DE}.Debug|x86.Build.0 = Debug|Any CPU + {753F7805-71E7-4441-AE38-5198F97467DE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {753F7805-71E7-4441-AE38-5198F97467DE}.Release|Any CPU.Build.0 = Release|Any CPU + {753F7805-71E7-4441-AE38-5198F97467DE}.Release|x64.ActiveCfg = Release|Any CPU + {753F7805-71E7-4441-AE38-5198F97467DE}.Release|x64.Build.0 = Release|Any CPU + {753F7805-71E7-4441-AE38-5198F97467DE}.Release|x86.ActiveCfg = Release|Any CPU + {753F7805-71E7-4441-AE38-5198F97467DE}.Release|x86.Build.0 = Release|Any CPU + {A3159D41-DDBE-4700-B275-3C2B9E1606A9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A3159D41-DDBE-4700-B275-3C2B9E1606A9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A3159D41-DDBE-4700-B275-3C2B9E1606A9}.Debug|x64.ActiveCfg = Debug|Any CPU + {A3159D41-DDBE-4700-B275-3C2B9E1606A9}.Debug|x64.Build.0 = Debug|Any CPU + {A3159D41-DDBE-4700-B275-3C2B9E1606A9}.Debug|x86.ActiveCfg = Debug|Any CPU + {A3159D41-DDBE-4700-B275-3C2B9E1606A9}.Debug|x86.Build.0 = Debug|Any CPU + {A3159D41-DDBE-4700-B275-3C2B9E1606A9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A3159D41-DDBE-4700-B275-3C2B9E1606A9}.Release|Any CPU.Build.0 = Release|Any CPU + {A3159D41-DDBE-4700-B275-3C2B9E1606A9}.Release|x64.ActiveCfg = Release|Any CPU + {A3159D41-DDBE-4700-B275-3C2B9E1606A9}.Release|x64.Build.0 = Release|Any CPU + {A3159D41-DDBE-4700-B275-3C2B9E1606A9}.Release|x86.ActiveCfg = Release|Any CPU + {A3159D41-DDBE-4700-B275-3C2B9E1606A9}.Release|x86.Build.0 = Release|Any CPU + {5E387D26-B4EE-4206-9538-C3F4F0788985}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5E387D26-B4EE-4206-9538-C3F4F0788985}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5E387D26-B4EE-4206-9538-C3F4F0788985}.Debug|x64.ActiveCfg = Debug|Any CPU + {5E387D26-B4EE-4206-9538-C3F4F0788985}.Debug|x64.Build.0 = Debug|Any CPU + {5E387D26-B4EE-4206-9538-C3F4F0788985}.Debug|x86.ActiveCfg = Debug|Any CPU + {5E387D26-B4EE-4206-9538-C3F4F0788985}.Debug|x86.Build.0 = Debug|Any CPU + {5E387D26-B4EE-4206-9538-C3F4F0788985}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5E387D26-B4EE-4206-9538-C3F4F0788985}.Release|Any CPU.Build.0 = Release|Any CPU + {5E387D26-B4EE-4206-9538-C3F4F0788985}.Release|x64.ActiveCfg = Release|Any CPU + {5E387D26-B4EE-4206-9538-C3F4F0788985}.Release|x64.Build.0 = Release|Any CPU + {5E387D26-B4EE-4206-9538-C3F4F0788985}.Release|x86.ActiveCfg = Release|Any CPU + {5E387D26-B4EE-4206-9538-C3F4F0788985}.Release|x86.Build.0 = Release|Any CPU + {81BD718A-A26A-49E2-9CCB-8B76EA5C56D5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {81BD718A-A26A-49E2-9CCB-8B76EA5C56D5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {81BD718A-A26A-49E2-9CCB-8B76EA5C56D5}.Debug|x64.ActiveCfg = Debug|Any CPU + {81BD718A-A26A-49E2-9CCB-8B76EA5C56D5}.Debug|x64.Build.0 = Debug|Any CPU + {81BD718A-A26A-49E2-9CCB-8B76EA5C56D5}.Debug|x86.ActiveCfg = Debug|Any CPU + {81BD718A-A26A-49E2-9CCB-8B76EA5C56D5}.Debug|x86.Build.0 = Debug|Any CPU + {81BD718A-A26A-49E2-9CCB-8B76EA5C56D5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {81BD718A-A26A-49E2-9CCB-8B76EA5C56D5}.Release|Any CPU.Build.0 = Release|Any CPU + {81BD718A-A26A-49E2-9CCB-8B76EA5C56D5}.Release|x64.ActiveCfg = Release|Any CPU + {81BD718A-A26A-49E2-9CCB-8B76EA5C56D5}.Release|x64.Build.0 = Release|Any CPU + {81BD718A-A26A-49E2-9CCB-8B76EA5C56D5}.Release|x86.ActiveCfg = Release|Any CPU + {81BD718A-A26A-49E2-9CCB-8B76EA5C56D5}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer/AccountOwnerServer.csproj b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer/AccountOwnerServer.csproj new file mode 100644 index 000000000..8d9f2e4a6 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer/AccountOwnerServer.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + enable + + + + + + + + + + + + diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer/Controllers/AccountController.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer/Controllers/AccountController.cs new file mode 100644 index 000000000..a3c8e8dc7 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer/Controllers/AccountController.cs @@ -0,0 +1,62 @@ +using Contracts; +using Entities.Extensions; +using Entities.Models; +using Microsoft.AspNetCore.Mvc; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using System; + +namespace AccountOwnerServer.Controllers; + +[Route("api/owners/{ownerId}/accounts")] +[ApiController] +public class AccountController : ControllerBase +{ + private readonly ILoggerManager _logger; + private readonly IRepositoryWrapper _repository; + + public AccountController(ILoggerManager logger, + IRepositoryWrapper repository) + { + _logger = logger; + _repository = repository; + } + + [HttpGet] + public async Task>> GetAccountsForOwner(Guid ownerId, [FromQuery] AccountParameters parameters) + { + var accounts = await _repository.Account.GetAccountsByOwner(ownerId, parameters); + + var metadata = new + { + accounts.TotalCount, + accounts.PageSize, + accounts.CurrentPage, + accounts.TotalPages, + accounts.HasNext, + accounts.HasPrevious + }; + + Response.Headers["X-Pagination"] = JsonSerializer.Serialize(metadata); + + _logger.LogInfo($"Returned {accounts.TotalCount} accounts from database."); + + return Ok(accounts); + } + + [HttpGet("{id}")] + public IActionResult GetAccountForOwner(Guid ownerId, Guid id) + { + var account = _repository.Account.GetAccountByOwner(ownerId, id); + + if (account.IsObjectNull()) + { + _logger.LogError($"Account with id: {id}, hasn't been found in db."); + return NotFound(); + } + + return Ok(account); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer/Controllers/OwnerController.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer/Controllers/OwnerController.cs new file mode 100644 index 000000000..ef0d08dea --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer/Controllers/OwnerController.cs @@ -0,0 +1,139 @@ +using Contracts; +using Entities.Extensions; +using Entities.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using System; + +namespace AccountOwnerServer.Controllers; + +[Route("api/owners")] +[ApiController] +public class OwnerController : ControllerBase +{ + private readonly ILoggerManager _logger; + private readonly IRepositoryWrapper _repository; + + public OwnerController(ILoggerManager logger, + IRepositoryWrapper repository) + { + _logger = logger; + _repository = repository; + } + + [HttpGet] + public async Task>> GetOwners([FromQuery] OwnerParameters ownerParameters) + { + if (!ownerParameters.ValidYearRange) + { + return Problem( + detail: "maxYearOfBirth must be greater than or equal to minYearOfBirth.", + statusCode: StatusCodes.Status400BadRequest); + } + + var owners = await _repository.Owner.GetOwners(ownerParameters); + + var metadata = new + { + owners.TotalCount, + owners.PageSize, + owners.CurrentPage, + owners.TotalPages, + owners.HasNext, + owners.HasPrevious + }; + + Response.Headers["X-Pagination"] = JsonSerializer.Serialize(metadata); + + _logger.LogInfo($"Returned {owners.TotalCount} owners from database."); + + return Ok(owners); + } + + [HttpGet("{id}", Name = "OwnerById")] + public IActionResult GetOwnerById(Guid id) + { + var owner = _repository.Owner.GetOwnerById(id); + + if (owner.IsEmptyObject()) + { + _logger.LogError($"Owner with id: {id}, hasn't been found in db."); + return NotFound(); + } + + _logger.LogInfo($"Returned owner with id: {id}"); + + return Ok(owner); + } + + [HttpPost] + public IActionResult CreateOwner([FromBody] Owner owner) + { + if (owner.IsObjectNull()) + { + _logger.LogError("Owner object sent from client is null."); + return BadRequest("Owner object is null"); + } + + if (!ModelState.IsValid) + { + _logger.LogError("Invalid owner object sent from client."); + return BadRequest("Invalid model object"); + } + + _repository.Owner.CreateOwner(owner); + _repository.Save(); + + return CreatedAtRoute("OwnerById", new { id = owner.Id }, owner); + } + + [HttpPut("{id}")] + public IActionResult UpdateOwner(Guid id, [FromBody] Owner owner) + { + if (owner.IsObjectNull()) + { + _logger.LogError("Owner object sent from client is null."); + return BadRequest("Owner object is null"); + } + + if (!ModelState.IsValid) + { + _logger.LogError("Invalid owner object sent from client."); + return BadRequest("Invalid model object"); + } + + var dbOwner = _repository.Owner.GetOwnerById(id); + + if (dbOwner.IsEmptyObject()) + { + _logger.LogError($"Owner with id: {id}, hasn't been found in db."); + return NotFound(); + } + + _repository.Owner.UpdateOwner(dbOwner, owner); + _repository.Save(); + + return NoContent(); + } + + [HttpDelete("{id}")] + public IActionResult DeleteOwner(Guid id) + { + var owner = _repository.Owner.GetOwnerById(id); + + if (owner.IsEmptyObject()) + { + _logger.LogError($"Owner with id: {id}, hasn't been found in db."); + return NotFound(); + } + + _repository.Owner.DeleteOwner(owner); + _repository.Save(); + + return NoContent(); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer/Extensions/ServiceExtensions.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer/Extensions/ServiceExtensions.cs new file mode 100644 index 000000000..3acc356d1 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer/Extensions/ServiceExtensions.cs @@ -0,0 +1,35 @@ +using Contracts; +using Entities; +using LoggerService; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Repository; + +namespace AccountOwnerServer.Extensions; + +public static class ServiceExtensions +{ + public static void ConfigureCors(this IServiceCollection services) => + services.AddCors(options => + options.AddPolicy("CorsPolicy", + builder => builder.WithOrigins("http://localhost:5000", "https://localhost:5001") + .AllowAnyMethod() + .AllowAnyHeader() + .AllowCredentials() + .WithExposedHeaders("X-Pagination"))); + + public static void ConfigureLoggerService(this IServiceCollection services) => + services.AddSingleton(); + + public static void ConfigureSqlContext(this IServiceCollection services, IConfiguration config) + { + var connectionString = config["sqlconnection:connectionString"]; + services.AddDbContext(o => o.UseSqlServer(connectionString)); + } + + public static void ConfigureRepositoryWrapper(this IServiceCollection services) + { + services.AddScoped(); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer/Program.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer/Program.cs new file mode 100644 index 000000000..b45a72e8c --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer/Program.cs @@ -0,0 +1,60 @@ +using AccountOwnerServer.Extensions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using NLog; +using System.Net; + +LogManager.Setup().LoadConfigurationFromFile("nlog.config"); + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.ConfigureCors(); +builder.Services.ConfigureLoggerService(); +builder.Services.ConfigureSqlContext(builder.Configuration); +builder.Services.ConfigureRepositoryWrapper(); + +builder.Services.AddControllers(); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) + app.UseDeveloperExceptionPage(); +else + app.UseHsts(); + +app.UseExceptionHandler(appError => +{ + appError.Run(async context => + { + context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; + context.Response.ContentType = "application/json"; + + var contextFeature = context.Features.Get(); + + if (contextFeature is not null) + { + await context.Response.WriteAsJsonAsync(new + { + context.Response.StatusCode, + Message = "Internal Server Error." + }); + } + }); +}); + +app.UseHttpsRedirection(); + +app.UseCors("CorsPolicy"); + +app.UseForwardedHeaders(new ForwardedHeadersOptions +{ + ForwardedHeaders = ForwardedHeaders.All +}); + +app.MapControllers(); + +app.Run(); diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer/Properties/launchSettings.json b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer/Properties/launchSettings.json new file mode 100644 index 000000000..a82b9223a --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "AccountOwnerServer": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "api/owners", + "applicationUrl": "https://localhost:5001;http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer/appsettings.Development.json b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer/appsettings.Development.json new file mode 100644 index 000000000..e203e9407 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + } + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer/appsettings.json b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer/appsettings.json new file mode 100644 index 000000000..0096cb3d4 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer/appsettings.json @@ -0,0 +1,11 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning" + } + }, + "sqlconnection": { + "connectionString": "Server=(localdb)\\mssqllocaldb;Database=AccountOwner;Trusted_Connection=True;TrustServerCertificate=True;" + }, + "AllowedHosts": "*" +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer/nlog.config b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer/nlog.config new file mode 100644 index 000000000..52f675aa6 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/AccountOwnerServer/nlog.config @@ -0,0 +1,17 @@ + + + + + + + + + + + diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Contracts/Contracts.csproj b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Contracts/Contracts.csproj new file mode 100644 index 000000000..b201bbf27 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Contracts/Contracts.csproj @@ -0,0 +1,12 @@ + + + + net10.0 + enable + + + + + + + diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Contracts/IAccountRepository.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Contracts/IAccountRepository.cs new file mode 100644 index 000000000..9a0730ab7 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Contracts/IAccountRepository.cs @@ -0,0 +1,12 @@ +using Entities.Helpers; +using Entities.Models; +using System; +using System.Threading.Tasks; + +namespace Contracts; + +public interface IAccountRepository : IRepositoryBase +{ + Task> GetAccountsByOwner(Guid ownerId, AccountParameters parameters); + Account? GetAccountByOwner(Guid ownerId, Guid id); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Contracts/ILoggerManager.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Contracts/ILoggerManager.cs new file mode 100644 index 000000000..42d30745d --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Contracts/ILoggerManager.cs @@ -0,0 +1,9 @@ +namespace Contracts; + +public interface ILoggerManager +{ + void LogInfo(string message); + void LogWarn(string message); + void LogDebug(string message); + void LogError(string message); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Contracts/IOwnerRepository.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Contracts/IOwnerRepository.cs new file mode 100644 index 000000000..137669404 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Contracts/IOwnerRepository.cs @@ -0,0 +1,15 @@ +using Entities.Helpers; +using Entities.Models; +using System; +using System.Threading.Tasks; + +namespace Contracts; + +public interface IOwnerRepository : IRepositoryBase +{ + Task> GetOwners(OwnerParameters ownerParameters); + Owner GetOwnerById(Guid ownerId); + void CreateOwner(Owner owner); + void UpdateOwner(Owner dbOwner, Owner owner); + void DeleteOwner(Owner owner); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Contracts/IRepositoryBase.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Contracts/IRepositoryBase.cs new file mode 100644 index 000000000..51283872d --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Contracts/IRepositoryBase.cs @@ -0,0 +1,14 @@ +using System; +using System.Linq; +using System.Linq.Expressions; + +namespace Contracts; + +public interface IRepositoryBase +{ + IQueryable FindAll(); + IQueryable FindByCondition(Expression> expression); + void Create(T entity); + void Update(T entity); + void Delete(T entity); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Contracts/IRepositoryWrapper.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Contracts/IRepositoryWrapper.cs new file mode 100644 index 000000000..b7dd600fe --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Contracts/IRepositoryWrapper.cs @@ -0,0 +1,8 @@ +namespace Contracts; + +public interface IRepositoryWrapper +{ + IOwnerRepository Owner { get; } + IAccountRepository Account { get; } + void Save(); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Entities/Entities.csproj b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Entities/Entities.csproj new file mode 100644 index 000000000..ea034f6d3 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Entities/Entities.csproj @@ -0,0 +1,12 @@ + + + + net10.0 + enable + + + + + + + diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Entities/Enumerations/AccountType.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Entities/Enumerations/AccountType.cs new file mode 100644 index 000000000..b84e0ad48 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Entities/Enumerations/AccountType.cs @@ -0,0 +1,8 @@ +namespace Entities.Enumerations; + +public enum AccountType +{ + Domestic, + Savings, + Foreign +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Entities/Extensions/IEntityExtensions.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Entities/Extensions/IEntityExtensions.cs new file mode 100644 index 000000000..243eafc70 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Entities/Extensions/IEntityExtensions.cs @@ -0,0 +1,10 @@ +using System; + +namespace Entities.Extensions; + +public static class IEntityExtensions +{ + public static bool IsObjectNull(this IEntity? entity) => entity is null; + + public static bool IsEmptyObject(this IEntity entity) => entity.Id.Equals(Guid.Empty); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Entities/Extensions/OwnerExtensions.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Entities/Extensions/OwnerExtensions.cs new file mode 100644 index 000000000..ba2768266 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Entities/Extensions/OwnerExtensions.cs @@ -0,0 +1,13 @@ +using Entities.Models; + +namespace Entities.Extensions; + +public static class OwnerExtensions +{ + public static void Map(this Owner dbOwner, Owner owner) + { + dbOwner.Name = owner.Name; + dbOwner.Address = owner.Address; + dbOwner.DateOfBirth = owner.DateOfBirth; + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Entities/Helpers/PagedList.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Entities/Helpers/PagedList.cs new file mode 100644 index 000000000..a81287b98 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Entities/Helpers/PagedList.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; + +namespace Entities.Helpers; + +public class PagedList : List +{ + public int CurrentPage { get; private set; } + public int TotalPages { get; private set; } + public int PageSize { get; private set; } + public int TotalCount { get; private set; } + + public bool HasPrevious => CurrentPage > 1; + public bool HasNext => CurrentPage < TotalPages; + + public PagedList() + { + } + + public PagedList(List items, int count, int pageNumber, int pageSize) + { + TotalCount = count; + PageSize = pageSize; + CurrentPage = pageNumber; + TotalPages = (int)Math.Ceiling(count / (double)pageSize); + + AddRange(items); + } + + public static async Task> ToPagedListAsync(IQueryable source, int pageNumber, int pageSize) + { + var count = await source.CountAsync(); + var items = await source.Skip((pageNumber - 1) * pageSize).Take(pageSize).ToListAsync(); + + return new PagedList(items, count, pageNumber, pageSize); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Entities/IEntity.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Entities/IEntity.cs new file mode 100644 index 000000000..825a8525b --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Entities/IEntity.cs @@ -0,0 +1,8 @@ +using System; + +namespace Entities; + +public interface IEntity +{ + Guid Id { get; set; } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Entities/Models/Account.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Entities/Models/Account.cs new file mode 100644 index 000000000..7a5590d9f --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Entities/Models/Account.cs @@ -0,0 +1,22 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Entities.Models; + +[Table("account")] +public class Account : IEntity +{ + [Key] + [Column("AccountId")] + public Guid Id { get; set; } + + [Required(ErrorMessage = "Date created is required")] + public DateTime DateCreated { get; set; } + + [Required(ErrorMessage = "Account type is required")] + public string AccountType { get; set; } = string.Empty; + + [Required(ErrorMessage = "Owner Id is required")] + public Guid OwnerId { get; set; } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Entities/Models/AccountParameters.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Entities/Models/AccountParameters.cs new file mode 100644 index 000000000..8da0393ae --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Entities/Models/AccountParameters.cs @@ -0,0 +1,5 @@ +namespace Entities.Models; + +public class AccountParameters : QueryStringParameters +{ +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Entities/Models/Owner.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Entities/Models/Owner.cs new file mode 100644 index 000000000..05b422d00 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Entities/Models/Owner.cs @@ -0,0 +1,24 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Entities.Models; + +[Table("owner")] +public class Owner : IEntity +{ + [Key] + [Column("OwnerId")] + public Guid Id { get; set; } + + [Required(ErrorMessage = "Name is required")] + [StringLength(60, ErrorMessage = "Name can't be longer than 60 characters")] + public string Name { get; set; } = string.Empty; + + [Required(ErrorMessage = "Date of birth is required")] + public DateTime DateOfBirth { get; set; } + + [Required(ErrorMessage = "Address is required")] + [StringLength(100, ErrorMessage = "Address can't be longer than 100 characters")] + public string Address { get; set; } = string.Empty; +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Entities/Models/OwnerParameters.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Entities/Models/OwnerParameters.cs new file mode 100644 index 000000000..4dda20527 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Entities/Models/OwnerParameters.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace Entities.Models; + +public class OwnerParameters : QueryStringParameters +{ + [Range(1900, 2100)] + public int? MinYearOfBirth { get; set; } + + [Range(1900, 2100)] + public int? MaxYearOfBirth { get; set; } + + public bool ValidYearRange => + MinYearOfBirth is null || MaxYearOfBirth is null || MaxYearOfBirth >= MinYearOfBirth; + + public string? SearchTerm { get; set; } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Entities/Models/QueryStringParameters.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Entities/Models/QueryStringParameters.cs new file mode 100644 index 000000000..b017779a9 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Entities/Models/QueryStringParameters.cs @@ -0,0 +1,23 @@ +using System; + +namespace Entities.Models; + +public abstract class QueryStringParameters +{ + const int MaxPageSize = 50; + + private int _pageNumber = 1; + private int _pageSize = 10; + + public int PageNumber + { + get => _pageNumber; + set => _pageNumber = Math.Max(value, 1); + } + + public int PageSize + { + get => _pageSize; + set => _pageSize = Math.Clamp(value, 1, MaxPageSize); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Entities/RepositoryContext.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Entities/RepositoryContext.cs new file mode 100644 index 000000000..1857e8e5f --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Entities/RepositoryContext.cs @@ -0,0 +1,15 @@ +using Entities.Models; +using Microsoft.EntityFrameworkCore; + +namespace Entities; + +public class RepositoryContext : DbContext +{ + public RepositoryContext(DbContextOptions options) + : base(options) + { + } + + public DbSet Owners { get; set; } = null!; + public DbSet Accounts { get; set; } = null!; +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/LoggerService/LoggerManager.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/LoggerService/LoggerManager.cs new file mode 100644 index 000000000..dd71d82d6 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/LoggerService/LoggerManager.cs @@ -0,0 +1,17 @@ +using Contracts; +using NLog; + +namespace LoggerService; + +public class LoggerManager : ILoggerManager +{ + private static readonly ILogger Logger = LogManager.GetCurrentClassLogger(); + + public void LogDebug(string message) => Logger.Debug(message); + + public void LogError(string message) => Logger.Error(message); + + public void LogInfo(string message) => Logger.Info(message); + + public void LogWarn(string message) => Logger.Warn(message); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/LoggerService/LoggerService.csproj b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/LoggerService/LoggerService.csproj new file mode 100644 index 000000000..8adf826f0 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/LoggerService/LoggerService.csproj @@ -0,0 +1,16 @@ + + + + net10.0 + enable + + + + + + + + + + + diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Repository/AccountRepository.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Repository/AccountRepository.cs new file mode 100644 index 000000000..888e94bed --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Repository/AccountRepository.cs @@ -0,0 +1,31 @@ +using Contracts; +using Entities; +using Entities.Helpers; +using Entities.Models; +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace Repository; + +public class AccountRepository : RepositoryBase, IAccountRepository +{ + public AccountRepository(RepositoryContext repositoryContext) + : base(repositoryContext) + { + } + + public Task> GetAccountsByOwner(Guid ownerId, AccountParameters parameters) + { + var accounts = FindByCondition(a => a.OwnerId.Equals(ownerId)); + + var sortedAccounts = accounts.OrderBy(a => a.DateCreated); + + return PagedList.ToPagedListAsync(sortedAccounts, + parameters.PageNumber, + parameters.PageSize); + } + + public Account? GetAccountByOwner(Guid ownerId, Guid id) => + FindByCondition(a => a.OwnerId.Equals(ownerId) && a.Id.Equals(id)).SingleOrDefault(); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Repository/Extensions/RepositoryOwnerExtensions.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Repository/Extensions/RepositoryOwnerExtensions.cs new file mode 100644 index 000000000..abf4cd02e --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Repository/Extensions/RepositoryOwnerExtensions.cs @@ -0,0 +1,17 @@ +using Entities.Models; +using System.Linq; + +namespace Repository.Extensions; + +public static class RepositoryOwnerExtensions +{ + public static IQueryable Search(this IQueryable owners, string? searchTerm) + { + if (string.IsNullOrWhiteSpace(searchTerm)) + return owners; + + var term = searchTerm.Trim(); + + return owners.Where(o => o.Name.Contains(term) || o.Address.Contains(term)); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Repository/OwnerRepository.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Repository/OwnerRepository.cs new file mode 100644 index 000000000..486f091c4 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Repository/OwnerRepository.cs @@ -0,0 +1,51 @@ +using Contracts; +using Entities; +using Entities.Extensions; +using Entities.Helpers; +using Entities.Models; +using Repository.Extensions; +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace Repository; + +public class OwnerRepository : RepositoryBase, IOwnerRepository +{ + public OwnerRepository(RepositoryContext repositoryContext) + : base(repositoryContext) + { + } + + public Task> GetOwners(OwnerParameters ownerParameters) + { + var owners = FindAll(); + + if (ownerParameters.MinYearOfBirth is { } minYear) + owners = owners.Where(o => o.DateOfBirth >= new DateTime(minYear, 1, 1)); + + if (ownerParameters.MaxYearOfBirth is { } maxYear) + owners = owners.Where(o => o.DateOfBirth < new DateTime(maxYear + 1, 1, 1)); + + owners = owners.Search(ownerParameters.SearchTerm); + + var sortedOwners = owners.OrderBy(o => o.Name); + + return PagedList.ToPagedListAsync(sortedOwners, + ownerParameters.PageNumber, + ownerParameters.PageSize); + } + + public Owner GetOwnerById(Guid ownerId) => + FindByCondition(owner => owner.Id.Equals(ownerId)).SingleOrDefault() ?? new Owner(); + + public void CreateOwner(Owner owner) => Create(owner); + + public void UpdateOwner(Owner dbOwner, Owner owner) + { + dbOwner.Map(owner); + Update(dbOwner); + } + + public void DeleteOwner(Owner owner) => Delete(owner); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Repository/Repository.csproj b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Repository/Repository.csproj new file mode 100644 index 000000000..61a67779d --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Repository/Repository.csproj @@ -0,0 +1,16 @@ + + + + net10.0 + enable + + + + + + + + + + + diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Repository/RepositoryBase.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Repository/RepositoryBase.cs new file mode 100644 index 000000000..51053f279 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Repository/RepositoryBase.cs @@ -0,0 +1,33 @@ +using Contracts; +using Entities; +using Microsoft.EntityFrameworkCore; +using System; +using System.Linq; +using System.Linq.Expressions; + +namespace Repository; + +public abstract class RepositoryBase : IRepositoryBase where T : class +{ + protected RepositoryContext RepositoryContext { get; set; } + + protected RepositoryBase(RepositoryContext repositoryContext) + { + RepositoryContext = repositoryContext; + } + + public IQueryable FindAll() => + RepositoryContext.Set() + .AsNoTracking(); + + public IQueryable FindByCondition(Expression> expression) => + RepositoryContext.Set() + .Where(expression) + .AsNoTracking(); + + public void Create(T entity) => RepositoryContext.Set().Add(entity); + + public void Update(T entity) => RepositoryContext.Set().Update(entity); + + public void Delete(T entity) => RepositoryContext.Set().Remove(entity); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Repository/RepositoryWrapper.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Repository/RepositoryWrapper.cs new file mode 100644 index 000000000..653c4a87c --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/Repository/RepositoryWrapper.cs @@ -0,0 +1,22 @@ +using Contracts; +using Entities; + +namespace Repository; + +public class RepositoryWrapper : IRepositoryWrapper +{ + private readonly RepositoryContext _repoContext; + private IOwnerRepository? _owner; + private IAccountRepository? _account; + + public RepositoryWrapper(RepositoryContext repositoryContext) + { + _repoContext = repositoryContext; + } + + public IOwnerRepository Owner => _owner ??= new OwnerRepository(_repoContext); + + public IAccountRepository Account => _account ??= new AccountRepository(_repoContext); + + public void Save() => _repoContext.SaveChanges(); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/_SqlServer_Init_Script/init.sql b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/_SqlServer_Init_Script/init.sql new file mode 100644 index 000000000..f8232089c --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Searching/_SqlServer_Init_Script/init.sql @@ -0,0 +1,44 @@ +-- SQL Server schema and seed data for the Advanced REST Concepts sample. +-- Run it against a database of your choice, then point sqlconnection:connectionString at it. +-- On Windows the default connection string uses LocalDB. Elsewhere, one line gets you a server: +-- docker run -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=Str0ng!Passw0rd" -p 1433:1433 -d mcr.microsoft.com/mssql/server:2022-latest + +IF OBJECT_ID('dbo.account', 'U') IS NOT NULL DROP TABLE dbo.account; +IF OBJECT_ID('dbo.owner', 'U') IS NOT NULL DROP TABLE dbo.owner; + +CREATE TABLE dbo.owner +( + OwnerId uniqueidentifier NOT NULL PRIMARY KEY, + Name nvarchar(60) NOT NULL, + DateOfBirth datetime2 NOT NULL, + Address nvarchar(100) NOT NULL +); + +CREATE TABLE dbo.account +( + AccountId uniqueidentifier NOT NULL PRIMARY KEY, + DateCreated datetime2 NOT NULL, + AccountType nvarchar(45) NOT NULL, + OwnerId uniqueidentifier NOT NULL + CONSTRAINT fk_account_owner REFERENCES dbo.owner (OwnerId) ON UPDATE CASCADE +); + +CREATE INDEX ix_owner_name ON dbo.owner (Name); +CREATE INDEX ix_owner_dateofbirth ON dbo.owner (DateOfBirth); + +INSERT INTO dbo.owner (OwnerId, Name, DateOfBirth, Address) VALUES + ('24fd81f8-d58a-4bcc-9f35-dc6cd5641906', 'John Keen', '1980-12-05', '61 Wellfield Road'), + ('261e1685-cf26-494c-b17c-3546e65f5620', 'Anna Bosh', '1974-11-14', '27 Colored Row'), + ('66774006-2371-4d5b-8518-2177bcf3f73e', 'Nick Somion', '1998-12-15', 'North sunny address 102'), + ('a3c1880c-674c-4d18-8f91-5d3608a2c937', 'Sam Query', '1990-04-22', '91 Western Roads'), + ('f98e4d74-0f68-4aac-89fd-047f1aaca6b6', 'Martin Miller', '1983-05-21', '3 Edgar Buildings'); + +INSERT INTO dbo.account (AccountId, DateCreated, AccountType, OwnerId) VALUES + ('03e91478-5608-4132-a753-d494dafce00b', '2003-12-15', 'Domestic', 'f98e4d74-0f68-4aac-89fd-047f1aaca6b6'), + ('356a5a9b-64bf-4de0-bc84-5395a1fdc9c4', '1996-02-15', 'Domestic', '261e1685-cf26-494c-b17c-3546e65f5620'), + ('371b93f2-f8c5-4a32-894a-fc672741aa5b', '1999-05-04', 'Domestic', '24fd81f8-d58a-4bcc-9f35-dc6cd5641906'), + ('670775db-ecc0-4b90-a9ab-37cd0d8e2801', '1999-12-21', 'Savings', '24fd81f8-d58a-4bcc-9f35-dc6cd5641906'), + ('a3fbad0b-7f48-4feb-8ac0-6d3bbc997bfc', '2010-05-28', 'Domestic', 'a3c1880c-674c-4d18-8f91-5d3608a2c937'), + ('aa15f658-04bb-4f73-82af-82db49d0fbef', '1999-05-12', 'Foreign', '24fd81f8-d58a-4bcc-9f35-dc6cd5641906'), + ('c6066eb0-53ca-43e1-97aa-3c2169eec659', '1996-02-16', 'Foreign', '261e1685-cf26-494c-b17c-3546e65f5620'), + ('eccadf79-85fe-402f-893c-32d3f03ed9b1', '2010-06-20', 'Foreign', 'a3c1880c-674c-4d18-8f91-5d3608a2c937'); diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer.Tests/AccountOwnerServer.Tests.csproj b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer.Tests/AccountOwnerServer.Tests.csproj new file mode 100644 index 000000000..338bac1ce --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer.Tests/AccountOwnerServer.Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + false + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer.Tests/OwnerParametersTests.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer.Tests/OwnerParametersTests.cs new file mode 100644 index 000000000..4ccdf0adb --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer.Tests/OwnerParametersTests.cs @@ -0,0 +1,41 @@ +using Entities.Models; +using Xunit; + +namespace AccountOwnerServer.Tests; + +public class OwnerParametersTests +{ + [Fact] + public void GivenTheSameMinAndMaxYear_WhenTheRangeIsValidated_ThenItIsAccepted() + { + var parameters = new OwnerParameters { MinYearOfBirth = 1990, MaxYearOfBirth = 1990 }; + + Assert.True(parameters.ValidYearRange); + } + + [Fact] + public void GivenAMaxYearBelowTheMinYear_WhenTheRangeIsValidated_ThenItIsRejected() + { + var parameters = new OwnerParameters { MinYearOfBirth = 1975, MaxYearOfBirth = 1974 }; + + Assert.False(parameters.ValidYearRange); + } + + [Fact] + public void GivenNoYearsAtAll_WhenTheRangeIsValidated_ThenItIsAccepted() + { + var parameters = new OwnerParameters(); + + Assert.Null(parameters.MinYearOfBirth); + Assert.Null(parameters.MaxYearOfBirth); + Assert.True(parameters.ValidYearRange); + } + + [Fact] + public void GivenOnlyAMinYear_WhenTheRangeIsValidated_ThenItIsAccepted() + { + var parameters = new OwnerParameters { MinYearOfBirth = 1975 }; + + Assert.True(parameters.ValidYearRange); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer.Tests/OwnerRepositorySqlServerTests.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer.Tests/OwnerRepositorySqlServerTests.cs new file mode 100644 index 000000000..a10b8ea3a --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer.Tests/OwnerRepositorySqlServerTests.cs @@ -0,0 +1,108 @@ +using Entities.Helpers; +using Entities.Models; +using Repository; +using System; +using System.Linq; +using System.Threading.Tasks; +using Xunit; + +namespace AccountOwnerServer.Tests; + +/// +/// These run against a real SQL Server started by Testcontainers. They are skipped unless +/// CODEMAZE_SQLSERVER_TESTS=1 is set, because the SQL Server image is not guaranteed to be +/// available on every machine or CI runner. +/// +public class OwnerRepositorySqlServerTests +{ + [SqlServerFact] + public async Task GivenAPageSizeOfTwo_WhenWeAskForTheSecondPage_ThenWeGetTwoOwnersAndTheRightMetadata() + { + await using var sample = await SqlServerSample.StartAsync(); + await using var context = sample.CreateContext(); + + var repository = new OwnerRepository(context, new SortHelper()); + + var owners = await repository.GetOwners(new OwnerParameters { PageNumber = 2, PageSize = 2 }); + + Assert.Equal(2, owners.Count); + Assert.Equal(5, owners.TotalCount); + Assert.Equal(3, owners.TotalPages); + Assert.True(owners.HasPrevious); + Assert.True(owners.HasNext); + } + + [SqlServerFact] + public async Task GivenTheSameMinAndMaxYear_WhenWeFilter_ThenTheOwnerBornInThatYearComesBack() + { + await using var sample = await SqlServerSample.StartAsync(); + await using var context = sample.CreateContext(); + + var repository = new OwnerRepository(context, new SortHelper()); + + var owners = await repository.GetOwners(new OwnerParameters + { + MinYearOfBirth = 1990, + MaxYearOfBirth = 1990 + }); + + Assert.Equal(1, owners.TotalCount); + Assert.Equal("Sam Query", NameOf(owners.Single())); + } + + [SqlServerFact] + public async Task GivenOnlyAMinimumYear_WhenWeFilter_ThenTheUpperBoundIsOpen() + { + await using var sample = await SqlServerSample.StartAsync(); + await using var context = sample.CreateContext(); + + var repository = new OwnerRepository(context, new SortHelper()); + + var owners = await repository.GetOwners(new OwnerParameters { MinYearOfBirth = 1990 }); + + Assert.Equal(2, owners.TotalCount); + } + + [SqlServerFact] + public async Task GivenALowerCaseSearchTerm_WhenWeSearch_ThenTheDefaultCollationStillMatches() + { + await using var sample = await SqlServerSample.StartAsync(); + await using var context = sample.CreateContext(); + + var repository = new OwnerRepository(context, new SortHelper()); + + var owners = await repository.GetOwners(new OwnerParameters { SearchTerm = "anna" }); + + Assert.Equal(1, owners.TotalCount); + Assert.Equal("Anna Bosh", NameOf(owners.Single())); + } + + [SqlServerFact] + public async Task GivenAnUnknownOrderByField_WhenWeQuery_ThenThePageIsStillOrderedRatherThanFailing() + { + await using var sample = await SqlServerSample.StartAsync(); + await using var context = sample.CreateContext(); + + var repository = new OwnerRepository(context, new SortHelper()); + + var owners = await repository.GetOwners(new OwnerParameters { OrderBy = "age" }); + + Assert.Equal(5, owners.TotalCount); + Assert.Equal("Anna Bosh", NameOf(owners.First())); + } + + [SqlServerFact] + public async Task GivenAnUpperCaseDescToken_WhenWeQuery_ThenTheOrderIsDescending() + { + await using var sample = await SqlServerSample.StartAsync(); + await using var context = sample.CreateContext(); + + var repository = new OwnerRepository(context, new SortHelper()); + + var owners = await repository.GetOwners(new OwnerParameters { OrderBy = "name DESC" }); + + Assert.Equal("Sam Query", NameOf(owners.First())); + } + + private static string NameOf(Owner owner) => owner.Name; +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer.Tests/QueryStringParametersTests.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer.Tests/QueryStringParametersTests.cs new file mode 100644 index 000000000..0b3791e6f --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer.Tests/QueryStringParametersTests.cs @@ -0,0 +1,39 @@ +using Entities.Models; +using Xunit; + +namespace AccountOwnerServer.Tests; + +public class QueryStringParametersTests +{ + [Theory] + [InlineData(0, 1)] + [InlineData(-3, 1)] + [InlineData(2, 2)] + public void GivenAPageNumber_WhenItIsSet_ThenItNeverDropsBelowOne(int requested, int expected) + { + var parameters = new OwnerParameters { PageNumber = requested }; + + Assert.Equal(expected, parameters.PageNumber); + } + + [Theory] + [InlineData(0, 1)] + [InlineData(-10, 1)] + [InlineData(7, 7)] + [InlineData(500, 50)] + public void GivenAPageSize_WhenItIsSet_ThenItIsClampedBetweenOneAndFifty(int requested, int expected) + { + var parameters = new OwnerParameters { PageSize = requested }; + + Assert.Equal(expected, parameters.PageSize); + } + + [Fact] + public void GivenNoQueryString_WhenParametersAreCreated_ThenTheDefaultsAreOneAndTen() + { + var parameters = new OwnerParameters(); + + Assert.Equal(1, parameters.PageNumber); + Assert.Equal(10, parameters.PageSize); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer.Tests/SearchExtensionTests.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer.Tests/SearchExtensionTests.cs new file mode 100644 index 000000000..e83fe53cc --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer.Tests/SearchExtensionTests.cs @@ -0,0 +1,48 @@ +using Entities.Models; +using Repository.Extensions; +using System.Linq; +using Xunit; + +namespace AccountOwnerServer.Tests; + +public class SearchExtensionTests +{ + private static IQueryable Owners() => SqlServerSample.Owners().AsQueryable(); + + [Fact] + public void GivenABlankSearchTerm_WhenWeSearch_ThenTheQueryIsReturnedUntouched() + { + var owners = Owners(); + + Assert.Same(owners, owners.Search(null)); + Assert.Same(owners, owners.Search(string.Empty)); + Assert.Same(owners, owners.Search(" ")); + } + + [Fact] + public void GivenANameFragment_WhenWeSearch_ThenTheMatchingOwnerComesBack() + { + var result = Owners().Search("Anna").ToList(); + + Assert.Single(result); + Assert.Equal("Anna Bosh", result[0].Name); + } + + [Fact] + public void GivenAnAddressFragment_WhenWeSearch_ThenTheColumnIsSearchedToo() + { + var result = Owners().Search("Wellfield").ToList(); + + Assert.Single(result); + Assert.Equal("John Keen", result[0].Name); + } + + [Fact] + public void GivenAPaddedSearchTerm_WhenWeSearch_ThenItIsTrimmedBeforeMatching() + { + var result = Owners().Search(" Somion ").ToList(); + + Assert.Single(result); + Assert.Equal("Nick Somion", result[0].Name); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer.Tests/SortHelperTests.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer.Tests/SortHelperTests.cs new file mode 100644 index 000000000..685ec28ad --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer.Tests/SortHelperTests.cs @@ -0,0 +1,61 @@ +using Entities.Helpers; +using Entities.Models; +using System.Linq; +using Xunit; + +namespace AccountOwnerServer.Tests; + +public class SortHelperTests +{ + private static IQueryable Owners() => SqlServerSample.Owners().AsQueryable(); + + private static readonly SortHelper Sorter = new(); + + [Fact] + public void GivenABlankOrderBy_WhenWeSort_ThenTheQueryIsReturnedUntouched() + { + var owners = Owners(); + + Assert.Same(owners, Sorter.ApplySort(owners, null)); + Assert.Same(owners, Sorter.ApplySort(owners, " ")); + } + + [Fact] + public void GivenOnlyUnknownFields_WhenWeSort_ThenTheQueryIsReturnedUntouchedRatherThanThrowing() + { + var owners = Owners(); + + var sorted = Sorter.ApplySort(owners, "age"); + + Assert.Same(owners, sorted); + } + + [Fact] + public void GivenAKnownField_WhenWeSort_ThenTheRowsComeBackInThatOrder() + { + var sorted = Sorter.ApplySort(Owners(), "dateOfBirth").ToList(); + + Assert.Equal("Anna Bosh", sorted.First().Name); + Assert.Equal("Nick Somion", sorted.Last().Name); + } + + [Theory] + [InlineData("name desc")] + [InlineData("name DESC")] + [InlineData("name Desc")] + public void GivenDescInAnyCasing_WhenWeSort_ThenTheOrderIsDescending(string orderBy) + { + var sorted = Sorter.ApplySort(Owners(), orderBy).ToList(); + + Assert.Equal("Sam Query", sorted.First().Name); + Assert.Equal("Anna Bosh", sorted.Last().Name); + } + + [Fact] + public void GivenAKnownAndAnUnknownField_WhenWeSort_ThenTheKnownOneStillApplies() + { + var sorted = Sorter.ApplySort(Owners(), "age,name desc").ToList(); + + Assert.Equal("Sam Query", sorted.First().Name); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer.Tests/SqlServerFactAttribute.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer.Tests/SqlServerFactAttribute.cs new file mode 100644 index 000000000..6ae7fafae --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer.Tests/SqlServerFactAttribute.cs @@ -0,0 +1,20 @@ +using System; +using Xunit; + +namespace AccountOwnerServer.Tests; + +/// +/// A fact that runs only when SQL Server integration tests are switched on with +/// CODEMAZE_SQLSERVER_TESTS=1. The tests need a working Docker daemon: Testcontainers +/// starts mcr.microsoft.com/mssql/server for them. +/// +public sealed class SqlServerFactAttribute : FactAttribute +{ + public SqlServerFactAttribute() + { + if (Environment.GetEnvironmentVariable("CODEMAZE_SQLSERVER_TESTS") != "1") + { + Skip = "Set CODEMAZE_SQLSERVER_TESTS=1 (and have Docker running) to run the SQL Server tests."; + } + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer.Tests/SqlServerSample.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer.Tests/SqlServerSample.cs new file mode 100644 index 000000000..e557d6730 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer.Tests/SqlServerSample.cs @@ -0,0 +1,73 @@ +using Entities; +using Entities.Models; +using Microsoft.EntityFrameworkCore; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Testcontainers.MsSql; + +namespace AccountOwnerServer.Tests; + +/// +/// A throwaway SQL Server container seeded with the five owners and eight accounts the +/// articles use. Each test owns its own container, so nothing is created when the tests +/// are skipped. +/// +public sealed class SqlServerSample : IAsyncDisposable +{ + private readonly MsSqlContainer _container; + + private SqlServerSample(MsSqlContainer container) + { + _container = container; + } + + public static async Task StartAsync() + { + var container = new MsSqlBuilder("mcr.microsoft.com/mssql/server:2022-latest").Build(); + await container.StartAsync(); + + var sample = new SqlServerSample(container); + + await using var context = sample.CreateContext(); + await context.Database.EnsureCreatedAsync(); + + context.Owners.AddRange(Owners()); + context.Accounts.AddRange(Accounts()); + await context.SaveChangesAsync(); + + return sample; + } + + public RepositoryContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseSqlServer(_container.GetConnectionString()) + .Options; + + return new RepositoryContext(options); + } + + public async ValueTask DisposeAsync() => await _container.DisposeAsync(); + + public static List Owners() => + [ + new() { Id = Guid.Parse("24fd81f8-d58a-4bcc-9f35-dc6cd5641906"), Name = "John Keen", DateOfBirth = new DateTime(1980, 12, 5), Address = "61 Wellfield Road" }, + new() { Id = Guid.Parse("261e1685-cf26-494c-b17c-3546e65f5620"), Name = "Anna Bosh", DateOfBirth = new DateTime(1974, 11, 14), Address = "27 Colored Row" }, + new() { Id = Guid.Parse("66774006-2371-4d5b-8518-2177bcf3f73e"), Name = "Nick Somion", DateOfBirth = new DateTime(1998, 12, 15), Address = "North sunny address 102" }, + new() { Id = Guid.Parse("a3c1880c-674c-4d18-8f91-5d3608a2c937"), Name = "Sam Query", DateOfBirth = new DateTime(1990, 4, 22), Address = "91 Western Roads" }, + new() { Id = Guid.Parse("f98e4d74-0f68-4aac-89fd-047f1aaca6b6"), Name = "Martin Miller", DateOfBirth = new DateTime(1983, 5, 21), Address = "3 Edgar Buildings" } + ]; + + public static List Accounts() => + [ + new() { Id = Guid.Parse("03e91478-5608-4132-a753-d494dafce00b"), DateCreated = new DateTime(2003, 12, 15), AccountType = "Domestic", OwnerId = Guid.Parse("f98e4d74-0f68-4aac-89fd-047f1aaca6b6") }, + new() { Id = Guid.Parse("356a5a9b-64bf-4de0-bc84-5395a1fdc9c4"), DateCreated = new DateTime(1996, 2, 15), AccountType = "Domestic", OwnerId = Guid.Parse("261e1685-cf26-494c-b17c-3546e65f5620") }, + new() { Id = Guid.Parse("371b93f2-f8c5-4a32-894a-fc672741aa5b"), DateCreated = new DateTime(1999, 5, 4), AccountType = "Domestic", OwnerId = Guid.Parse("24fd81f8-d58a-4bcc-9f35-dc6cd5641906") }, + new() { Id = Guid.Parse("670775db-ecc0-4b90-a9ab-37cd0d8e2801"), DateCreated = new DateTime(1999, 12, 21), AccountType = "Savings", OwnerId = Guid.Parse("24fd81f8-d58a-4bcc-9f35-dc6cd5641906") }, + new() { Id = Guid.Parse("a3fbad0b-7f48-4feb-8ac0-6d3bbc997bfc"), DateCreated = new DateTime(2010, 5, 28), AccountType = "Domestic", OwnerId = Guid.Parse("a3c1880c-674c-4d18-8f91-5d3608a2c937") }, + new() { Id = Guid.Parse("aa15f658-04bb-4f73-82af-82db49d0fbef"), DateCreated = new DateTime(1999, 5, 12), AccountType = "Foreign", OwnerId = Guid.Parse("24fd81f8-d58a-4bcc-9f35-dc6cd5641906") }, + new() { Id = Guid.Parse("c6066eb0-53ca-43e1-97aa-3c2169eec659"), DateCreated = new DateTime(1996, 2, 16), AccountType = "Foreign", OwnerId = Guid.Parse("261e1685-cf26-494c-b17c-3546e65f5620") }, + new() { Id = Guid.Parse("eccadf79-85fe-402f-893c-32d3f03ed9b1"), DateCreated = new DateTime(2010, 6, 20), AccountType = "Foreign", OwnerId = Guid.Parse("a3c1880c-674c-4d18-8f91-5d3608a2c937") } + ]; +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer.sln b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer.sln new file mode 100644 index 000000000..a83e583fa --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer.sln @@ -0,0 +1,104 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AccountOwnerServer", "AccountOwnerServer\AccountOwnerServer.csproj", "{4CEAD235-11AB-48C9-BEB2-B694FA435904}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LoggerService", "LoggerService\LoggerService.csproj", "{B7E68FE5-0E6E-4A66-8CB4-5EF1427EC3B9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contracts", "Contracts\Contracts.csproj", "{0E24C1E1-01F0-4700-A959-7659AD8E1461}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Entities", "Entities\Entities.csproj", "{D290E278-49BF-483F-BFB4-758FC5FC4516}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Repository", "Repository\Repository.csproj", "{A3E42EB0-C368-4A8B-A60C-67411C68209D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AccountOwnerServer.Tests", "AccountOwnerServer.Tests\AccountOwnerServer.Tests.csproj", "{08869923-759C-4B3D-ADD3-05663D05B2B3}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {4CEAD235-11AB-48C9-BEB2-B694FA435904}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4CEAD235-11AB-48C9-BEB2-B694FA435904}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4CEAD235-11AB-48C9-BEB2-B694FA435904}.Debug|x64.ActiveCfg = Debug|Any CPU + {4CEAD235-11AB-48C9-BEB2-B694FA435904}.Debug|x64.Build.0 = Debug|Any CPU + {4CEAD235-11AB-48C9-BEB2-B694FA435904}.Debug|x86.ActiveCfg = Debug|Any CPU + {4CEAD235-11AB-48C9-BEB2-B694FA435904}.Debug|x86.Build.0 = Debug|Any CPU + {4CEAD235-11AB-48C9-BEB2-B694FA435904}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4CEAD235-11AB-48C9-BEB2-B694FA435904}.Release|Any CPU.Build.0 = Release|Any CPU + {4CEAD235-11AB-48C9-BEB2-B694FA435904}.Release|x64.ActiveCfg = Release|Any CPU + {4CEAD235-11AB-48C9-BEB2-B694FA435904}.Release|x64.Build.0 = Release|Any CPU + {4CEAD235-11AB-48C9-BEB2-B694FA435904}.Release|x86.ActiveCfg = Release|Any CPU + {4CEAD235-11AB-48C9-BEB2-B694FA435904}.Release|x86.Build.0 = Release|Any CPU + {B7E68FE5-0E6E-4A66-8CB4-5EF1427EC3B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B7E68FE5-0E6E-4A66-8CB4-5EF1427EC3B9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B7E68FE5-0E6E-4A66-8CB4-5EF1427EC3B9}.Debug|x64.ActiveCfg = Debug|Any CPU + {B7E68FE5-0E6E-4A66-8CB4-5EF1427EC3B9}.Debug|x64.Build.0 = Debug|Any CPU + {B7E68FE5-0E6E-4A66-8CB4-5EF1427EC3B9}.Debug|x86.ActiveCfg = Debug|Any CPU + {B7E68FE5-0E6E-4A66-8CB4-5EF1427EC3B9}.Debug|x86.Build.0 = Debug|Any CPU + {B7E68FE5-0E6E-4A66-8CB4-5EF1427EC3B9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B7E68FE5-0E6E-4A66-8CB4-5EF1427EC3B9}.Release|Any CPU.Build.0 = Release|Any CPU + {B7E68FE5-0E6E-4A66-8CB4-5EF1427EC3B9}.Release|x64.ActiveCfg = Release|Any CPU + {B7E68FE5-0E6E-4A66-8CB4-5EF1427EC3B9}.Release|x64.Build.0 = Release|Any CPU + {B7E68FE5-0E6E-4A66-8CB4-5EF1427EC3B9}.Release|x86.ActiveCfg = Release|Any CPU + {B7E68FE5-0E6E-4A66-8CB4-5EF1427EC3B9}.Release|x86.Build.0 = Release|Any CPU + {0E24C1E1-01F0-4700-A959-7659AD8E1461}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0E24C1E1-01F0-4700-A959-7659AD8E1461}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0E24C1E1-01F0-4700-A959-7659AD8E1461}.Debug|x64.ActiveCfg = Debug|Any CPU + {0E24C1E1-01F0-4700-A959-7659AD8E1461}.Debug|x64.Build.0 = Debug|Any CPU + {0E24C1E1-01F0-4700-A959-7659AD8E1461}.Debug|x86.ActiveCfg = Debug|Any CPU + {0E24C1E1-01F0-4700-A959-7659AD8E1461}.Debug|x86.Build.0 = Debug|Any CPU + {0E24C1E1-01F0-4700-A959-7659AD8E1461}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0E24C1E1-01F0-4700-A959-7659AD8E1461}.Release|Any CPU.Build.0 = Release|Any CPU + {0E24C1E1-01F0-4700-A959-7659AD8E1461}.Release|x64.ActiveCfg = Release|Any CPU + {0E24C1E1-01F0-4700-A959-7659AD8E1461}.Release|x64.Build.0 = Release|Any CPU + {0E24C1E1-01F0-4700-A959-7659AD8E1461}.Release|x86.ActiveCfg = Release|Any CPU + {0E24C1E1-01F0-4700-A959-7659AD8E1461}.Release|x86.Build.0 = Release|Any CPU + {D290E278-49BF-483F-BFB4-758FC5FC4516}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D290E278-49BF-483F-BFB4-758FC5FC4516}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D290E278-49BF-483F-BFB4-758FC5FC4516}.Debug|x64.ActiveCfg = Debug|Any CPU + {D290E278-49BF-483F-BFB4-758FC5FC4516}.Debug|x64.Build.0 = Debug|Any CPU + {D290E278-49BF-483F-BFB4-758FC5FC4516}.Debug|x86.ActiveCfg = Debug|Any CPU + {D290E278-49BF-483F-BFB4-758FC5FC4516}.Debug|x86.Build.0 = Debug|Any CPU + {D290E278-49BF-483F-BFB4-758FC5FC4516}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D290E278-49BF-483F-BFB4-758FC5FC4516}.Release|Any CPU.Build.0 = Release|Any CPU + {D290E278-49BF-483F-BFB4-758FC5FC4516}.Release|x64.ActiveCfg = Release|Any CPU + {D290E278-49BF-483F-BFB4-758FC5FC4516}.Release|x64.Build.0 = Release|Any CPU + {D290E278-49BF-483F-BFB4-758FC5FC4516}.Release|x86.ActiveCfg = Release|Any CPU + {D290E278-49BF-483F-BFB4-758FC5FC4516}.Release|x86.Build.0 = Release|Any CPU + {A3E42EB0-C368-4A8B-A60C-67411C68209D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A3E42EB0-C368-4A8B-A60C-67411C68209D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A3E42EB0-C368-4A8B-A60C-67411C68209D}.Debug|x64.ActiveCfg = Debug|Any CPU + {A3E42EB0-C368-4A8B-A60C-67411C68209D}.Debug|x64.Build.0 = Debug|Any CPU + {A3E42EB0-C368-4A8B-A60C-67411C68209D}.Debug|x86.ActiveCfg = Debug|Any CPU + {A3E42EB0-C368-4A8B-A60C-67411C68209D}.Debug|x86.Build.0 = Debug|Any CPU + {A3E42EB0-C368-4A8B-A60C-67411C68209D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A3E42EB0-C368-4A8B-A60C-67411C68209D}.Release|Any CPU.Build.0 = Release|Any CPU + {A3E42EB0-C368-4A8B-A60C-67411C68209D}.Release|x64.ActiveCfg = Release|Any CPU + {A3E42EB0-C368-4A8B-A60C-67411C68209D}.Release|x64.Build.0 = Release|Any CPU + {A3E42EB0-C368-4A8B-A60C-67411C68209D}.Release|x86.ActiveCfg = Release|Any CPU + {A3E42EB0-C368-4A8B-A60C-67411C68209D}.Release|x86.Build.0 = Release|Any CPU + {08869923-759C-4B3D-ADD3-05663D05B2B3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {08869923-759C-4B3D-ADD3-05663D05B2B3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {08869923-759C-4B3D-ADD3-05663D05B2B3}.Debug|x64.ActiveCfg = Debug|Any CPU + {08869923-759C-4B3D-ADD3-05663D05B2B3}.Debug|x64.Build.0 = Debug|Any CPU + {08869923-759C-4B3D-ADD3-05663D05B2B3}.Debug|x86.ActiveCfg = Debug|Any CPU + {08869923-759C-4B3D-ADD3-05663D05B2B3}.Debug|x86.Build.0 = Debug|Any CPU + {08869923-759C-4B3D-ADD3-05663D05B2B3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {08869923-759C-4B3D-ADD3-05663D05B2B3}.Release|Any CPU.Build.0 = Release|Any CPU + {08869923-759C-4B3D-ADD3-05663D05B2B3}.Release|x64.ActiveCfg = Release|Any CPU + {08869923-759C-4B3D-ADD3-05663D05B2B3}.Release|x64.Build.0 = Release|Any CPU + {08869923-759C-4B3D-ADD3-05663D05B2B3}.Release|x86.ActiveCfg = Release|Any CPU + {08869923-759C-4B3D-ADD3-05663D05B2B3}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer/AccountOwnerServer.csproj b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer/AccountOwnerServer.csproj new file mode 100644 index 000000000..8d9f2e4a6 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer/AccountOwnerServer.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + enable + + + + + + + + + + + + diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer/Controllers/AccountController.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer/Controllers/AccountController.cs new file mode 100644 index 000000000..a3c8e8dc7 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer/Controllers/AccountController.cs @@ -0,0 +1,62 @@ +using Contracts; +using Entities.Extensions; +using Entities.Models; +using Microsoft.AspNetCore.Mvc; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using System; + +namespace AccountOwnerServer.Controllers; + +[Route("api/owners/{ownerId}/accounts")] +[ApiController] +public class AccountController : ControllerBase +{ + private readonly ILoggerManager _logger; + private readonly IRepositoryWrapper _repository; + + public AccountController(ILoggerManager logger, + IRepositoryWrapper repository) + { + _logger = logger; + _repository = repository; + } + + [HttpGet] + public async Task>> GetAccountsForOwner(Guid ownerId, [FromQuery] AccountParameters parameters) + { + var accounts = await _repository.Account.GetAccountsByOwner(ownerId, parameters); + + var metadata = new + { + accounts.TotalCount, + accounts.PageSize, + accounts.CurrentPage, + accounts.TotalPages, + accounts.HasNext, + accounts.HasPrevious + }; + + Response.Headers["X-Pagination"] = JsonSerializer.Serialize(metadata); + + _logger.LogInfo($"Returned {accounts.TotalCount} accounts from database."); + + return Ok(accounts); + } + + [HttpGet("{id}")] + public IActionResult GetAccountForOwner(Guid ownerId, Guid id) + { + var account = _repository.Account.GetAccountByOwner(ownerId, id); + + if (account.IsObjectNull()) + { + _logger.LogError($"Account with id: {id}, hasn't been found in db."); + return NotFound(); + } + + return Ok(account); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer/Controllers/OwnerController.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer/Controllers/OwnerController.cs new file mode 100644 index 000000000..ef0d08dea --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer/Controllers/OwnerController.cs @@ -0,0 +1,139 @@ +using Contracts; +using Entities.Extensions; +using Entities.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using System; + +namespace AccountOwnerServer.Controllers; + +[Route("api/owners")] +[ApiController] +public class OwnerController : ControllerBase +{ + private readonly ILoggerManager _logger; + private readonly IRepositoryWrapper _repository; + + public OwnerController(ILoggerManager logger, + IRepositoryWrapper repository) + { + _logger = logger; + _repository = repository; + } + + [HttpGet] + public async Task>> GetOwners([FromQuery] OwnerParameters ownerParameters) + { + if (!ownerParameters.ValidYearRange) + { + return Problem( + detail: "maxYearOfBirth must be greater than or equal to minYearOfBirth.", + statusCode: StatusCodes.Status400BadRequest); + } + + var owners = await _repository.Owner.GetOwners(ownerParameters); + + var metadata = new + { + owners.TotalCount, + owners.PageSize, + owners.CurrentPage, + owners.TotalPages, + owners.HasNext, + owners.HasPrevious + }; + + Response.Headers["X-Pagination"] = JsonSerializer.Serialize(metadata); + + _logger.LogInfo($"Returned {owners.TotalCount} owners from database."); + + return Ok(owners); + } + + [HttpGet("{id}", Name = "OwnerById")] + public IActionResult GetOwnerById(Guid id) + { + var owner = _repository.Owner.GetOwnerById(id); + + if (owner.IsEmptyObject()) + { + _logger.LogError($"Owner with id: {id}, hasn't been found in db."); + return NotFound(); + } + + _logger.LogInfo($"Returned owner with id: {id}"); + + return Ok(owner); + } + + [HttpPost] + public IActionResult CreateOwner([FromBody] Owner owner) + { + if (owner.IsObjectNull()) + { + _logger.LogError("Owner object sent from client is null."); + return BadRequest("Owner object is null"); + } + + if (!ModelState.IsValid) + { + _logger.LogError("Invalid owner object sent from client."); + return BadRequest("Invalid model object"); + } + + _repository.Owner.CreateOwner(owner); + _repository.Save(); + + return CreatedAtRoute("OwnerById", new { id = owner.Id }, owner); + } + + [HttpPut("{id}")] + public IActionResult UpdateOwner(Guid id, [FromBody] Owner owner) + { + if (owner.IsObjectNull()) + { + _logger.LogError("Owner object sent from client is null."); + return BadRequest("Owner object is null"); + } + + if (!ModelState.IsValid) + { + _logger.LogError("Invalid owner object sent from client."); + return BadRequest("Invalid model object"); + } + + var dbOwner = _repository.Owner.GetOwnerById(id); + + if (dbOwner.IsEmptyObject()) + { + _logger.LogError($"Owner with id: {id}, hasn't been found in db."); + return NotFound(); + } + + _repository.Owner.UpdateOwner(dbOwner, owner); + _repository.Save(); + + return NoContent(); + } + + [HttpDelete("{id}")] + public IActionResult DeleteOwner(Guid id) + { + var owner = _repository.Owner.GetOwnerById(id); + + if (owner.IsEmptyObject()) + { + _logger.LogError($"Owner with id: {id}, hasn't been found in db."); + return NotFound(); + } + + _repository.Owner.DeleteOwner(owner); + _repository.Save(); + + return NoContent(); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer/Extensions/ServiceExtensions.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer/Extensions/ServiceExtensions.cs new file mode 100644 index 000000000..a5c689f20 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer/Extensions/ServiceExtensions.cs @@ -0,0 +1,39 @@ +using Contracts; +using Entities.Helpers; +using Entities.Models; +using Entities; +using LoggerService; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Repository; + +namespace AccountOwnerServer.Extensions; + +public static class ServiceExtensions +{ + public static void ConfigureCors(this IServiceCollection services) => + services.AddCors(options => + options.AddPolicy("CorsPolicy", + builder => builder.WithOrigins("http://localhost:5000", "https://localhost:5001") + .AllowAnyMethod() + .AllowAnyHeader() + .AllowCredentials() + .WithExposedHeaders("X-Pagination"))); + + public static void ConfigureLoggerService(this IServiceCollection services) => + services.AddSingleton(); + + public static void ConfigureSqlContext(this IServiceCollection services, IConfiguration config) + { + var connectionString = config["sqlconnection:connectionString"]; + services.AddDbContext(o => o.UseSqlServer(connectionString)); + } + + public static void ConfigureRepositoryWrapper(this IServiceCollection services) + { + services.AddScoped, SortHelper>(); + services.AddScoped, SortHelper>(); + services.AddScoped(); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer/Program.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer/Program.cs new file mode 100644 index 000000000..b45a72e8c --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer/Program.cs @@ -0,0 +1,60 @@ +using AccountOwnerServer.Extensions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using NLog; +using System.Net; + +LogManager.Setup().LoadConfigurationFromFile("nlog.config"); + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.ConfigureCors(); +builder.Services.ConfigureLoggerService(); +builder.Services.ConfigureSqlContext(builder.Configuration); +builder.Services.ConfigureRepositoryWrapper(); + +builder.Services.AddControllers(); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) + app.UseDeveloperExceptionPage(); +else + app.UseHsts(); + +app.UseExceptionHandler(appError => +{ + appError.Run(async context => + { + context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; + context.Response.ContentType = "application/json"; + + var contextFeature = context.Features.Get(); + + if (contextFeature is not null) + { + await context.Response.WriteAsJsonAsync(new + { + context.Response.StatusCode, + Message = "Internal Server Error." + }); + } + }); +}); + +app.UseHttpsRedirection(); + +app.UseCors("CorsPolicy"); + +app.UseForwardedHeaders(new ForwardedHeadersOptions +{ + ForwardedHeaders = ForwardedHeaders.All +}); + +app.MapControllers(); + +app.Run(); diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer/Properties/launchSettings.json b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer/Properties/launchSettings.json new file mode 100644 index 000000000..a82b9223a --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "AccountOwnerServer": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "api/owners", + "applicationUrl": "https://localhost:5001;http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer/appsettings.Development.json b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer/appsettings.Development.json new file mode 100644 index 000000000..e203e9407 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + } + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer/appsettings.json b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer/appsettings.json new file mode 100644 index 000000000..0096cb3d4 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer/appsettings.json @@ -0,0 +1,11 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning" + } + }, + "sqlconnection": { + "connectionString": "Server=(localdb)\\mssqllocaldb;Database=AccountOwner;Trusted_Connection=True;TrustServerCertificate=True;" + }, + "AllowedHosts": "*" +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer/nlog.config b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer/nlog.config new file mode 100644 index 000000000..52f675aa6 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/AccountOwnerServer/nlog.config @@ -0,0 +1,17 @@ + + + + + + + + + + + diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Contracts/Contracts.csproj b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Contracts/Contracts.csproj new file mode 100644 index 000000000..b201bbf27 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Contracts/Contracts.csproj @@ -0,0 +1,12 @@ + + + + net10.0 + enable + + + + + + + diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Contracts/IAccountRepository.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Contracts/IAccountRepository.cs new file mode 100644 index 000000000..9a0730ab7 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Contracts/IAccountRepository.cs @@ -0,0 +1,12 @@ +using Entities.Helpers; +using Entities.Models; +using System; +using System.Threading.Tasks; + +namespace Contracts; + +public interface IAccountRepository : IRepositoryBase +{ + Task> GetAccountsByOwner(Guid ownerId, AccountParameters parameters); + Account? GetAccountByOwner(Guid ownerId, Guid id); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Contracts/ILoggerManager.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Contracts/ILoggerManager.cs new file mode 100644 index 000000000..42d30745d --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Contracts/ILoggerManager.cs @@ -0,0 +1,9 @@ +namespace Contracts; + +public interface ILoggerManager +{ + void LogInfo(string message); + void LogWarn(string message); + void LogDebug(string message); + void LogError(string message); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Contracts/IOwnerRepository.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Contracts/IOwnerRepository.cs new file mode 100644 index 000000000..137669404 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Contracts/IOwnerRepository.cs @@ -0,0 +1,15 @@ +using Entities.Helpers; +using Entities.Models; +using System; +using System.Threading.Tasks; + +namespace Contracts; + +public interface IOwnerRepository : IRepositoryBase +{ + Task> GetOwners(OwnerParameters ownerParameters); + Owner GetOwnerById(Guid ownerId); + void CreateOwner(Owner owner); + void UpdateOwner(Owner dbOwner, Owner owner); + void DeleteOwner(Owner owner); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Contracts/IRepositoryBase.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Contracts/IRepositoryBase.cs new file mode 100644 index 000000000..51283872d --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Contracts/IRepositoryBase.cs @@ -0,0 +1,14 @@ +using System; +using System.Linq; +using System.Linq.Expressions; + +namespace Contracts; + +public interface IRepositoryBase +{ + IQueryable FindAll(); + IQueryable FindByCondition(Expression> expression); + void Create(T entity); + void Update(T entity); + void Delete(T entity); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Contracts/IRepositoryWrapper.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Contracts/IRepositoryWrapper.cs new file mode 100644 index 000000000..b7dd600fe --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Contracts/IRepositoryWrapper.cs @@ -0,0 +1,8 @@ +namespace Contracts; + +public interface IRepositoryWrapper +{ + IOwnerRepository Owner { get; } + IAccountRepository Account { get; } + void Save(); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Entities/Entities.csproj b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Entities/Entities.csproj new file mode 100644 index 000000000..4aa0cf347 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Entities/Entities.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + + + + + + + + diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Entities/Enumerations/AccountType.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Entities/Enumerations/AccountType.cs new file mode 100644 index 000000000..b84e0ad48 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Entities/Enumerations/AccountType.cs @@ -0,0 +1,8 @@ +namespace Entities.Enumerations; + +public enum AccountType +{ + Domestic, + Savings, + Foreign +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Entities/Extensions/IEntityExtensions.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Entities/Extensions/IEntityExtensions.cs new file mode 100644 index 000000000..243eafc70 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Entities/Extensions/IEntityExtensions.cs @@ -0,0 +1,10 @@ +using System; + +namespace Entities.Extensions; + +public static class IEntityExtensions +{ + public static bool IsObjectNull(this IEntity? entity) => entity is null; + + public static bool IsEmptyObject(this IEntity entity) => entity.Id.Equals(Guid.Empty); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Entities/Extensions/OwnerExtensions.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Entities/Extensions/OwnerExtensions.cs new file mode 100644 index 000000000..ba2768266 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Entities/Extensions/OwnerExtensions.cs @@ -0,0 +1,13 @@ +using Entities.Models; + +namespace Entities.Extensions; + +public static class OwnerExtensions +{ + public static void Map(this Owner dbOwner, Owner owner) + { + dbOwner.Name = owner.Name; + dbOwner.Address = owner.Address; + dbOwner.DateOfBirth = owner.DateOfBirth; + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Entities/Helpers/ISortHelper.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Entities/Helpers/ISortHelper.cs new file mode 100644 index 000000000..3d867c508 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Entities/Helpers/ISortHelper.cs @@ -0,0 +1,8 @@ +using System.Linq; + +namespace Entities.Helpers; + +public interface ISortHelper +{ + IQueryable ApplySort(IQueryable entities, string? orderByQueryString); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Entities/Helpers/PagedList.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Entities/Helpers/PagedList.cs new file mode 100644 index 000000000..a81287b98 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Entities/Helpers/PagedList.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; + +namespace Entities.Helpers; + +public class PagedList : List +{ + public int CurrentPage { get; private set; } + public int TotalPages { get; private set; } + public int PageSize { get; private set; } + public int TotalCount { get; private set; } + + public bool HasPrevious => CurrentPage > 1; + public bool HasNext => CurrentPage < TotalPages; + + public PagedList() + { + } + + public PagedList(List items, int count, int pageNumber, int pageSize) + { + TotalCount = count; + PageSize = pageSize; + CurrentPage = pageNumber; + TotalPages = (int)Math.Ceiling(count / (double)pageSize); + + AddRange(items); + } + + public static async Task> ToPagedListAsync(IQueryable source, int pageNumber, int pageSize) + { + var count = await source.CountAsync(); + var items = await source.Skip((pageNumber - 1) * pageSize).Take(pageSize).ToListAsync(); + + return new PagedList(items, count, pageNumber, pageSize); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Entities/Helpers/SortHelper.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Entities/Helpers/SortHelper.cs new file mode 100644 index 000000000..705154cbe --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Entities/Helpers/SortHelper.cs @@ -0,0 +1,46 @@ +using System; +using System.Linq; +using System.Linq.Dynamic.Core; +using System.Reflection; +using System.Text; + +namespace Entities.Helpers; + +public class SortHelper : ISortHelper +{ + public IQueryable ApplySort(IQueryable entities, string? orderByQueryString) + { + if (string.IsNullOrWhiteSpace(orderByQueryString)) + return entities; + + var orderParams = orderByQueryString.Trim().Split(','); + var propertyInfos = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance); + var orderQueryBuilder = new StringBuilder(); + + foreach (var param in orderParams) + { + if (string.IsNullOrWhiteSpace(param)) + continue; + + var propertyFromQueryName = param.Split(' ')[0]; + var objectProperty = propertyInfos.FirstOrDefault(pi => + pi.Name.Equals(propertyFromQueryName, StringComparison.InvariantCultureIgnoreCase)); + + if (objectProperty is null) + continue; + + var sortingOrder = param.EndsWith(" desc", StringComparison.OrdinalIgnoreCase) + ? "descending" + : "ascending"; + + orderQueryBuilder.Append($"{objectProperty.Name} {sortingOrder}, "); + } + + var orderQuery = orderQueryBuilder.ToString().TrimEnd(',', ' '); + + if (string.IsNullOrWhiteSpace(orderQuery)) + return entities; + + return entities.OrderBy(orderQuery); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Entities/IEntity.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Entities/IEntity.cs new file mode 100644 index 000000000..825a8525b --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Entities/IEntity.cs @@ -0,0 +1,8 @@ +using System; + +namespace Entities; + +public interface IEntity +{ + Guid Id { get; set; } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Entities/Models/Account.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Entities/Models/Account.cs new file mode 100644 index 000000000..7a5590d9f --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Entities/Models/Account.cs @@ -0,0 +1,22 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Entities.Models; + +[Table("account")] +public class Account : IEntity +{ + [Key] + [Column("AccountId")] + public Guid Id { get; set; } + + [Required(ErrorMessage = "Date created is required")] + public DateTime DateCreated { get; set; } + + [Required(ErrorMessage = "Account type is required")] + public string AccountType { get; set; } = string.Empty; + + [Required(ErrorMessage = "Owner Id is required")] + public Guid OwnerId { get; set; } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Entities/Models/AccountParameters.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Entities/Models/AccountParameters.cs new file mode 100644 index 000000000..448b50178 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Entities/Models/AccountParameters.cs @@ -0,0 +1,9 @@ +namespace Entities.Models; + +public class AccountParameters : QueryStringParameters +{ + public AccountParameters() + { + OrderBy = "DateCreated"; + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Entities/Models/Owner.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Entities/Models/Owner.cs new file mode 100644 index 000000000..05b422d00 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Entities/Models/Owner.cs @@ -0,0 +1,24 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Entities.Models; + +[Table("owner")] +public class Owner : IEntity +{ + [Key] + [Column("OwnerId")] + public Guid Id { get; set; } + + [Required(ErrorMessage = "Name is required")] + [StringLength(60, ErrorMessage = "Name can't be longer than 60 characters")] + public string Name { get; set; } = string.Empty; + + [Required(ErrorMessage = "Date of birth is required")] + public DateTime DateOfBirth { get; set; } + + [Required(ErrorMessage = "Address is required")] + [StringLength(100, ErrorMessage = "Address can't be longer than 100 characters")] + public string Address { get; set; } = string.Empty; +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Entities/Models/OwnerParameters.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Entities/Models/OwnerParameters.cs new file mode 100644 index 000000000..55d4cbe80 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Entities/Models/OwnerParameters.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; + +namespace Entities.Models; + +public class OwnerParameters : QueryStringParameters +{ + public OwnerParameters() + { + OrderBy = "name"; + } + + [Range(1900, 2100)] + public int? MinYearOfBirth { get; set; } + + [Range(1900, 2100)] + public int? MaxYearOfBirth { get; set; } + + public bool ValidYearRange => + MinYearOfBirth is null || MaxYearOfBirth is null || MaxYearOfBirth >= MinYearOfBirth; + + public string? SearchTerm { get; set; } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Entities/Models/QueryStringParameters.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Entities/Models/QueryStringParameters.cs new file mode 100644 index 000000000..538424f75 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Entities/Models/QueryStringParameters.cs @@ -0,0 +1,25 @@ +using System; + +namespace Entities.Models; + +public abstract class QueryStringParameters +{ + const int MaxPageSize = 50; + + private int _pageNumber = 1; + private int _pageSize = 10; + + public int PageNumber + { + get => _pageNumber; + set => _pageNumber = Math.Max(value, 1); + } + + public int PageSize + { + get => _pageSize; + set => _pageSize = Math.Clamp(value, 1, MaxPageSize); + } + + public string? OrderBy { get; set; } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Entities/RepositoryContext.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Entities/RepositoryContext.cs new file mode 100644 index 000000000..1857e8e5f --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Entities/RepositoryContext.cs @@ -0,0 +1,15 @@ +using Entities.Models; +using Microsoft.EntityFrameworkCore; + +namespace Entities; + +public class RepositoryContext : DbContext +{ + public RepositoryContext(DbContextOptions options) + : base(options) + { + } + + public DbSet Owners { get; set; } = null!; + public DbSet Accounts { get; set; } = null!; +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/LoggerService/LoggerManager.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/LoggerService/LoggerManager.cs new file mode 100644 index 000000000..dd71d82d6 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/LoggerService/LoggerManager.cs @@ -0,0 +1,17 @@ +using Contracts; +using NLog; + +namespace LoggerService; + +public class LoggerManager : ILoggerManager +{ + private static readonly ILogger Logger = LogManager.GetCurrentClassLogger(); + + public void LogDebug(string message) => Logger.Debug(message); + + public void LogError(string message) => Logger.Error(message); + + public void LogInfo(string message) => Logger.Info(message); + + public void LogWarn(string message) => Logger.Warn(message); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/LoggerService/LoggerService.csproj b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/LoggerService/LoggerService.csproj new file mode 100644 index 000000000..8adf826f0 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/LoggerService/LoggerService.csproj @@ -0,0 +1,16 @@ + + + + net10.0 + enable + + + + + + + + + + + diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Repository/AccountRepository.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Repository/AccountRepository.cs new file mode 100644 index 000000000..a545a68c6 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Repository/AccountRepository.cs @@ -0,0 +1,35 @@ +using Contracts; +using Entities; +using Entities.Helpers; +using Entities.Models; +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace Repository; + +public class AccountRepository : RepositoryBase, IAccountRepository +{ + private readonly ISortHelper _sortHelper; + + public AccountRepository(RepositoryContext repositoryContext, + ISortHelper sortHelper) + : base(repositoryContext) + { + _sortHelper = sortHelper; + } + + public Task> GetAccountsByOwner(Guid ownerId, AccountParameters parameters) + { + var accounts = FindByCondition(a => a.OwnerId.Equals(ownerId)); + + var sortedAccounts = _sortHelper.ApplySort(accounts.OrderBy(a => a.DateCreated), parameters.OrderBy); + + return PagedList.ToPagedListAsync(sortedAccounts, + parameters.PageNumber, + parameters.PageSize); + } + + public Account? GetAccountByOwner(Guid ownerId, Guid id) => + FindByCondition(a => a.OwnerId.Equals(ownerId) && a.Id.Equals(id)).SingleOrDefault(); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Repository/Extensions/RepositoryOwnerExtensions.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Repository/Extensions/RepositoryOwnerExtensions.cs new file mode 100644 index 000000000..abf4cd02e --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Repository/Extensions/RepositoryOwnerExtensions.cs @@ -0,0 +1,17 @@ +using Entities.Models; +using System.Linq; + +namespace Repository.Extensions; + +public static class RepositoryOwnerExtensions +{ + public static IQueryable Search(this IQueryable owners, string? searchTerm) + { + if (string.IsNullOrWhiteSpace(searchTerm)) + return owners; + + var term = searchTerm.Trim(); + + return owners.Where(o => o.Name.Contains(term) || o.Address.Contains(term)); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Repository/OwnerRepository.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Repository/OwnerRepository.cs new file mode 100644 index 000000000..2ccf8b7ad --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Repository/OwnerRepository.cs @@ -0,0 +1,55 @@ +using Contracts; +using Entities; +using Entities.Extensions; +using Entities.Helpers; +using Entities.Models; +using Repository.Extensions; +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace Repository; + +public class OwnerRepository : RepositoryBase, IOwnerRepository +{ + private readonly ISortHelper _sortHelper; + + public OwnerRepository(RepositoryContext repositoryContext, + ISortHelper sortHelper) + : base(repositoryContext) + { + _sortHelper = sortHelper; + } + + public Task> GetOwners(OwnerParameters ownerParameters) + { + var owners = FindAll(); + + if (ownerParameters.MinYearOfBirth is { } minYear) + owners = owners.Where(o => o.DateOfBirth >= new DateTime(minYear, 1, 1)); + + if (ownerParameters.MaxYearOfBirth is { } maxYear) + owners = owners.Where(o => o.DateOfBirth < new DateTime(maxYear + 1, 1, 1)); + + owners = owners.Search(ownerParameters.SearchTerm); + + var sortedOwners = _sortHelper.ApplySort(owners.OrderBy(o => o.Name), ownerParameters.OrderBy); + + return PagedList.ToPagedListAsync(sortedOwners, + ownerParameters.PageNumber, + ownerParameters.PageSize); + } + + public Owner GetOwnerById(Guid ownerId) => + FindByCondition(owner => owner.Id.Equals(ownerId)).SingleOrDefault() ?? new Owner(); + + public void CreateOwner(Owner owner) => Create(owner); + + public void UpdateOwner(Owner dbOwner, Owner owner) + { + dbOwner.Map(owner); + Update(dbOwner); + } + + public void DeleteOwner(Owner owner) => Delete(owner); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Repository/Repository.csproj b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Repository/Repository.csproj new file mode 100644 index 000000000..61a67779d --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Repository/Repository.csproj @@ -0,0 +1,16 @@ + + + + net10.0 + enable + + + + + + + + + + + diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Repository/RepositoryBase.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Repository/RepositoryBase.cs new file mode 100644 index 000000000..51053f279 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Repository/RepositoryBase.cs @@ -0,0 +1,33 @@ +using Contracts; +using Entities; +using Microsoft.EntityFrameworkCore; +using System; +using System.Linq; +using System.Linq.Expressions; + +namespace Repository; + +public abstract class RepositoryBase : IRepositoryBase where T : class +{ + protected RepositoryContext RepositoryContext { get; set; } + + protected RepositoryBase(RepositoryContext repositoryContext) + { + RepositoryContext = repositoryContext; + } + + public IQueryable FindAll() => + RepositoryContext.Set() + .AsNoTracking(); + + public IQueryable FindByCondition(Expression> expression) => + RepositoryContext.Set() + .Where(expression) + .AsNoTracking(); + + public void Create(T entity) => RepositoryContext.Set().Add(entity); + + public void Update(T entity) => RepositoryContext.Set().Update(entity); + + public void Delete(T entity) => RepositoryContext.Set().Remove(entity); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Repository/RepositoryWrapper.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Repository/RepositoryWrapper.cs new file mode 100644 index 000000000..a763262e2 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/Repository/RepositoryWrapper.cs @@ -0,0 +1,30 @@ +using Contracts; +using Entities; +using Entities.Helpers; +using Entities.Models; + +namespace Repository; + +public class RepositoryWrapper : IRepositoryWrapper +{ + private readonly RepositoryContext _repoContext; + private readonly ISortHelper _ownerSortHelper; + private readonly ISortHelper _accountSortHelper; + private IOwnerRepository? _owner; + private IAccountRepository? _account; + + public RepositoryWrapper(RepositoryContext repositoryContext, + ISortHelper ownerSortHelper, + ISortHelper accountSortHelper) + { + _repoContext = repositoryContext; + _ownerSortHelper = ownerSortHelper; + _accountSortHelper = accountSortHelper; + } + + public IOwnerRepository Owner => _owner ??= new OwnerRepository(_repoContext, _ownerSortHelper); + + public IAccountRepository Account => _account ??= new AccountRepository(_repoContext, _accountSortHelper); + + public void Save() => _repoContext.SaveChanges(); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/_SqlServer_Init_Script/init.sql b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/_SqlServer_Init_Script/init.sql new file mode 100644 index 000000000..f8232089c --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/Sorting/_SqlServer_Init_Script/init.sql @@ -0,0 +1,44 @@ +-- SQL Server schema and seed data for the Advanced REST Concepts sample. +-- Run it against a database of your choice, then point sqlconnection:connectionString at it. +-- On Windows the default connection string uses LocalDB. Elsewhere, one line gets you a server: +-- docker run -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=Str0ng!Passw0rd" -p 1433:1433 -d mcr.microsoft.com/mssql/server:2022-latest + +IF OBJECT_ID('dbo.account', 'U') IS NOT NULL DROP TABLE dbo.account; +IF OBJECT_ID('dbo.owner', 'U') IS NOT NULL DROP TABLE dbo.owner; + +CREATE TABLE dbo.owner +( + OwnerId uniqueidentifier NOT NULL PRIMARY KEY, + Name nvarchar(60) NOT NULL, + DateOfBirth datetime2 NOT NULL, + Address nvarchar(100) NOT NULL +); + +CREATE TABLE dbo.account +( + AccountId uniqueidentifier NOT NULL PRIMARY KEY, + DateCreated datetime2 NOT NULL, + AccountType nvarchar(45) NOT NULL, + OwnerId uniqueidentifier NOT NULL + CONSTRAINT fk_account_owner REFERENCES dbo.owner (OwnerId) ON UPDATE CASCADE +); + +CREATE INDEX ix_owner_name ON dbo.owner (Name); +CREATE INDEX ix_owner_dateofbirth ON dbo.owner (DateOfBirth); + +INSERT INTO dbo.owner (OwnerId, Name, DateOfBirth, Address) VALUES + ('24fd81f8-d58a-4bcc-9f35-dc6cd5641906', 'John Keen', '1980-12-05', '61 Wellfield Road'), + ('261e1685-cf26-494c-b17c-3546e65f5620', 'Anna Bosh', '1974-11-14', '27 Colored Row'), + ('66774006-2371-4d5b-8518-2177bcf3f73e', 'Nick Somion', '1998-12-15', 'North sunny address 102'), + ('a3c1880c-674c-4d18-8f91-5d3608a2c937', 'Sam Query', '1990-04-22', '91 Western Roads'), + ('f98e4d74-0f68-4aac-89fd-047f1aaca6b6', 'Martin Miller', '1983-05-21', '3 Edgar Buildings'); + +INSERT INTO dbo.account (AccountId, DateCreated, AccountType, OwnerId) VALUES + ('03e91478-5608-4132-a753-d494dafce00b', '2003-12-15', 'Domestic', 'f98e4d74-0f68-4aac-89fd-047f1aaca6b6'), + ('356a5a9b-64bf-4de0-bc84-5395a1fdc9c4', '1996-02-15', 'Domestic', '261e1685-cf26-494c-b17c-3546e65f5620'), + ('371b93f2-f8c5-4a32-894a-fc672741aa5b', '1999-05-04', 'Domestic', '24fd81f8-d58a-4bcc-9f35-dc6cd5641906'), + ('670775db-ecc0-4b90-a9ab-37cd0d8e2801', '1999-12-21', 'Savings', '24fd81f8-d58a-4bcc-9f35-dc6cd5641906'), + ('a3fbad0b-7f48-4feb-8ac0-6d3bbc997bfc', '2010-05-28', 'Domestic', 'a3c1880c-674c-4d18-8f91-5d3608a2c937'), + ('aa15f658-04bb-4f73-82af-82db49d0fbef', '1999-05-12', 'Foreign', '24fd81f8-d58a-4bcc-9f35-dc6cd5641906'), + ('c6066eb0-53ca-43e1-97aa-3c2169eec659', '1996-02-16', 'Foreign', '261e1685-cf26-494c-b17c-3546e65f5620'), + ('eccadf79-85fe-402f-893c-32d3f03ed9b1', '2010-06-20', 'Foreign', 'a3c1880c-674c-4d18-8f91-5d3608a2c937'); diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/AccountOwnerServer.sln b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/AccountOwnerServer.sln new file mode 100644 index 000000000..e6503b615 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/AccountOwnerServer.sln @@ -0,0 +1,90 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AccountOwnerServer", "AccountOwnerServer\AccountOwnerServer.csproj", "{71580BE9-55BA-488D-A987-147F3AD508DA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LoggerService", "LoggerService\LoggerService.csproj", "{72F28C2D-E2E4-4207-81F4-DCA5B8DAC227}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contracts", "Contracts\Contracts.csproj", "{D6C8AD99-F560-46D6-80C4-AB6CB5A1B3BA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Entities", "Entities\Entities.csproj", "{6B92D015-9D7D-4AD0-8950-EC808B5690F6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Repository", "Repository\Repository.csproj", "{258D81F2-C5B4-4A0D-B1FD-A0585F4DBA59}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {71580BE9-55BA-488D-A987-147F3AD508DA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {71580BE9-55BA-488D-A987-147F3AD508DA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {71580BE9-55BA-488D-A987-147F3AD508DA}.Debug|x64.ActiveCfg = Debug|Any CPU + {71580BE9-55BA-488D-A987-147F3AD508DA}.Debug|x64.Build.0 = Debug|Any CPU + {71580BE9-55BA-488D-A987-147F3AD508DA}.Debug|x86.ActiveCfg = Debug|Any CPU + {71580BE9-55BA-488D-A987-147F3AD508DA}.Debug|x86.Build.0 = Debug|Any CPU + {71580BE9-55BA-488D-A987-147F3AD508DA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {71580BE9-55BA-488D-A987-147F3AD508DA}.Release|Any CPU.Build.0 = Release|Any CPU + {71580BE9-55BA-488D-A987-147F3AD508DA}.Release|x64.ActiveCfg = Release|Any CPU + {71580BE9-55BA-488D-A987-147F3AD508DA}.Release|x64.Build.0 = Release|Any CPU + {71580BE9-55BA-488D-A987-147F3AD508DA}.Release|x86.ActiveCfg = Release|Any CPU + {71580BE9-55BA-488D-A987-147F3AD508DA}.Release|x86.Build.0 = Release|Any CPU + {72F28C2D-E2E4-4207-81F4-DCA5B8DAC227}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {72F28C2D-E2E4-4207-81F4-DCA5B8DAC227}.Debug|Any CPU.Build.0 = Debug|Any CPU + {72F28C2D-E2E4-4207-81F4-DCA5B8DAC227}.Debug|x64.ActiveCfg = Debug|Any CPU + {72F28C2D-E2E4-4207-81F4-DCA5B8DAC227}.Debug|x64.Build.0 = Debug|Any CPU + {72F28C2D-E2E4-4207-81F4-DCA5B8DAC227}.Debug|x86.ActiveCfg = Debug|Any CPU + {72F28C2D-E2E4-4207-81F4-DCA5B8DAC227}.Debug|x86.Build.0 = Debug|Any CPU + {72F28C2D-E2E4-4207-81F4-DCA5B8DAC227}.Release|Any CPU.ActiveCfg = Release|Any CPU + {72F28C2D-E2E4-4207-81F4-DCA5B8DAC227}.Release|Any CPU.Build.0 = Release|Any CPU + {72F28C2D-E2E4-4207-81F4-DCA5B8DAC227}.Release|x64.ActiveCfg = Release|Any CPU + {72F28C2D-E2E4-4207-81F4-DCA5B8DAC227}.Release|x64.Build.0 = Release|Any CPU + {72F28C2D-E2E4-4207-81F4-DCA5B8DAC227}.Release|x86.ActiveCfg = Release|Any CPU + {72F28C2D-E2E4-4207-81F4-DCA5B8DAC227}.Release|x86.Build.0 = Release|Any CPU + {D6C8AD99-F560-46D6-80C4-AB6CB5A1B3BA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D6C8AD99-F560-46D6-80C4-AB6CB5A1B3BA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D6C8AD99-F560-46D6-80C4-AB6CB5A1B3BA}.Debug|x64.ActiveCfg = Debug|Any CPU + {D6C8AD99-F560-46D6-80C4-AB6CB5A1B3BA}.Debug|x64.Build.0 = Debug|Any CPU + {D6C8AD99-F560-46D6-80C4-AB6CB5A1B3BA}.Debug|x86.ActiveCfg = Debug|Any CPU + {D6C8AD99-F560-46D6-80C4-AB6CB5A1B3BA}.Debug|x86.Build.0 = Debug|Any CPU + {D6C8AD99-F560-46D6-80C4-AB6CB5A1B3BA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D6C8AD99-F560-46D6-80C4-AB6CB5A1B3BA}.Release|Any CPU.Build.0 = Release|Any CPU + {D6C8AD99-F560-46D6-80C4-AB6CB5A1B3BA}.Release|x64.ActiveCfg = Release|Any CPU + {D6C8AD99-F560-46D6-80C4-AB6CB5A1B3BA}.Release|x64.Build.0 = Release|Any CPU + {D6C8AD99-F560-46D6-80C4-AB6CB5A1B3BA}.Release|x86.ActiveCfg = Release|Any CPU + {D6C8AD99-F560-46D6-80C4-AB6CB5A1B3BA}.Release|x86.Build.0 = Release|Any CPU + {6B92D015-9D7D-4AD0-8950-EC808B5690F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6B92D015-9D7D-4AD0-8950-EC808B5690F6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6B92D015-9D7D-4AD0-8950-EC808B5690F6}.Debug|x64.ActiveCfg = Debug|Any CPU + {6B92D015-9D7D-4AD0-8950-EC808B5690F6}.Debug|x64.Build.0 = Debug|Any CPU + {6B92D015-9D7D-4AD0-8950-EC808B5690F6}.Debug|x86.ActiveCfg = Debug|Any CPU + {6B92D015-9D7D-4AD0-8950-EC808B5690F6}.Debug|x86.Build.0 = Debug|Any CPU + {6B92D015-9D7D-4AD0-8950-EC808B5690F6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6B92D015-9D7D-4AD0-8950-EC808B5690F6}.Release|Any CPU.Build.0 = Release|Any CPU + {6B92D015-9D7D-4AD0-8950-EC808B5690F6}.Release|x64.ActiveCfg = Release|Any CPU + {6B92D015-9D7D-4AD0-8950-EC808B5690F6}.Release|x64.Build.0 = Release|Any CPU + {6B92D015-9D7D-4AD0-8950-EC808B5690F6}.Release|x86.ActiveCfg = Release|Any CPU + {6B92D015-9D7D-4AD0-8950-EC808B5690F6}.Release|x86.Build.0 = Release|Any CPU + {258D81F2-C5B4-4A0D-B1FD-A0585F4DBA59}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {258D81F2-C5B4-4A0D-B1FD-A0585F4DBA59}.Debug|Any CPU.Build.0 = Debug|Any CPU + {258D81F2-C5B4-4A0D-B1FD-A0585F4DBA59}.Debug|x64.ActiveCfg = Debug|Any CPU + {258D81F2-C5B4-4A0D-B1FD-A0585F4DBA59}.Debug|x64.Build.0 = Debug|Any CPU + {258D81F2-C5B4-4A0D-B1FD-A0585F4DBA59}.Debug|x86.ActiveCfg = Debug|Any CPU + {258D81F2-C5B4-4A0D-B1FD-A0585F4DBA59}.Debug|x86.Build.0 = Debug|Any CPU + {258D81F2-C5B4-4A0D-B1FD-A0585F4DBA59}.Release|Any CPU.ActiveCfg = Release|Any CPU + {258D81F2-C5B4-4A0D-B1FD-A0585F4DBA59}.Release|Any CPU.Build.0 = Release|Any CPU + {258D81F2-C5B4-4A0D-B1FD-A0585F4DBA59}.Release|x64.ActiveCfg = Release|Any CPU + {258D81F2-C5B4-4A0D-B1FD-A0585F4DBA59}.Release|x64.Build.0 = Release|Any CPU + {258D81F2-C5B4-4A0D-B1FD-A0585F4DBA59}.Release|x86.ActiveCfg = Release|Any CPU + {258D81F2-C5B4-4A0D-B1FD-A0585F4DBA59}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/AccountOwnerServer/AccountOwnerServer.csproj b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/AccountOwnerServer/AccountOwnerServer.csproj new file mode 100644 index 000000000..8d9f2e4a6 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/AccountOwnerServer/AccountOwnerServer.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + enable + + + + + + + + + + + + diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/AccountOwnerServer/Controllers/AccountController.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/AccountOwnerServer/Controllers/AccountController.cs new file mode 100644 index 000000000..944e13625 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/AccountOwnerServer/Controllers/AccountController.cs @@ -0,0 +1,44 @@ +using Contracts; +using Entities.Extensions; +using Microsoft.AspNetCore.Mvc; +using System; + +namespace AccountOwnerServer.Controllers; + +[Route("api/owners/{ownerId}/accounts")] +[ApiController] +public class AccountController : ControllerBase +{ + private readonly ILoggerManager _logger; + private readonly IRepositoryWrapper _repository; + + public AccountController(ILoggerManager logger, IRepositoryWrapper repository) + { + _logger = logger; + _repository = repository; + } + + [HttpGet] + public IActionResult GetAccountsForOwner(Guid ownerId) + { + var accounts = _repository.Account.GetAccountsByOwner(ownerId); + + _logger.LogInfo("Returned all accounts from database."); + + return Ok(accounts); + } + + [HttpGet("{id}")] + public IActionResult GetAccountForOwner(Guid ownerId, Guid id) + { + var account = _repository.Account.GetAccountByOwner(ownerId, id); + + if (account.IsObjectNull()) + { + _logger.LogError($"Account with id: {id}, hasn't been found in db."); + return NotFound(); + } + + return Ok(account); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/AccountOwnerServer/Controllers/OwnerController.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/AccountOwnerServer/Controllers/OwnerController.cs new file mode 100644 index 000000000..95c3743c0 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/AccountOwnerServer/Controllers/OwnerController.cs @@ -0,0 +1,114 @@ +using Contracts; +using Entities.Extensions; +using Entities.Models; +using Microsoft.AspNetCore.Mvc; +using System; + +namespace AccountOwnerServer.Controllers; + +[Route("api/owners")] +[ApiController] +public class OwnerController : ControllerBase +{ + private readonly ILoggerManager _logger; + private readonly IRepositoryWrapper _repository; + + public OwnerController(ILoggerManager logger, IRepositoryWrapper repository) + { + _logger = logger; + _repository = repository; + } + + [HttpGet] + public IActionResult GetOwners() + { + var owners = _repository.Owner.GetOwners(); + + _logger.LogInfo("Returned all owners from database."); + + return Ok(owners); + } + + [HttpGet("{id}", Name = "OwnerById")] + public IActionResult GetOwnerById(Guid id) + { + var owner = _repository.Owner.GetOwnerById(id); + + if (owner.IsEmptyObject()) + { + _logger.LogError($"Owner with id: {id}, hasn't been found in db."); + return NotFound(); + } + + _logger.LogInfo($"Returned owner with id: {id}"); + + return Ok(owner); + } + + [HttpPost] + public IActionResult CreateOwner([FromBody] Owner owner) + { + if (owner.IsObjectNull()) + { + _logger.LogError("Owner object sent from client is null."); + return BadRequest("Owner object is null"); + } + + if (!ModelState.IsValid) + { + _logger.LogError("Invalid owner object sent from client."); + return BadRequest("Invalid model object"); + } + + _repository.Owner.CreateOwner(owner); + _repository.Save(); + + return CreatedAtRoute("OwnerById", new { id = owner.Id }, owner); + } + + [HttpPut("{id}")] + public IActionResult UpdateOwner(Guid id, [FromBody] Owner owner) + { + if (owner.IsObjectNull()) + { + _logger.LogError("Owner object sent from client is null."); + return BadRequest("Owner object is null"); + } + + if (!ModelState.IsValid) + { + _logger.LogError("Invalid owner object sent from client."); + return BadRequest("Invalid model object"); + } + + var dbOwner = _repository.Owner.GetOwnerById(id); + + if (dbOwner.IsEmptyObject()) + { + _logger.LogError($"Owner with id: {id}, hasn't been found in db."); + return NotFound(); + } + + _repository.Owner.UpdateOwner(dbOwner, owner); + _repository.Save(); + + return NoContent(); + } + + [HttpDelete("{id}")] + public IActionResult DeleteOwner(Guid id) + { + var owner = _repository.Owner.GetOwnerById(id); + + if (owner.IsEmptyObject()) + { + _logger.LogError($"Owner with id: {id}, hasn't been found in db."); + return NotFound(); + } + + _repository.Owner.DeleteOwner(owner); + _repository.Save(); + + return NoContent(); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/AccountOwnerServer/Extensions/ServiceExtensions.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/AccountOwnerServer/Extensions/ServiceExtensions.cs new file mode 100644 index 000000000..7341a88db --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/AccountOwnerServer/Extensions/ServiceExtensions.cs @@ -0,0 +1,34 @@ +using Contracts; +using Entities; +using LoggerService; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Repository; + +namespace AccountOwnerServer.Extensions; + +public static class ServiceExtensions +{ + public static void ConfigureCors(this IServiceCollection services) => + services.AddCors(options => + options.AddPolicy("CorsPolicy", + builder => builder.WithOrigins("http://localhost:5000", "https://localhost:5001") + .AllowAnyMethod() + .AllowAnyHeader() + .AllowCredentials())); + + public static void ConfigureLoggerService(this IServiceCollection services) => + services.AddSingleton(); + + public static void ConfigureSqlContext(this IServiceCollection services, IConfiguration config) + { + var connectionString = config["sqlconnection:connectionString"]; + services.AddDbContext(o => o.UseSqlServer(connectionString)); + } + + public static void ConfigureRepositoryWrapper(this IServiceCollection services) + { + services.AddScoped(); + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/AccountOwnerServer/Program.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/AccountOwnerServer/Program.cs new file mode 100644 index 000000000..b45a72e8c --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/AccountOwnerServer/Program.cs @@ -0,0 +1,60 @@ +using AccountOwnerServer.Extensions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using NLog; +using System.Net; + +LogManager.Setup().LoadConfigurationFromFile("nlog.config"); + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.ConfigureCors(); +builder.Services.ConfigureLoggerService(); +builder.Services.ConfigureSqlContext(builder.Configuration); +builder.Services.ConfigureRepositoryWrapper(); + +builder.Services.AddControllers(); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) + app.UseDeveloperExceptionPage(); +else + app.UseHsts(); + +app.UseExceptionHandler(appError => +{ + appError.Run(async context => + { + context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; + context.Response.ContentType = "application/json"; + + var contextFeature = context.Features.Get(); + + if (contextFeature is not null) + { + await context.Response.WriteAsJsonAsync(new + { + context.Response.StatusCode, + Message = "Internal Server Error." + }); + } + }); +}); + +app.UseHttpsRedirection(); + +app.UseCors("CorsPolicy"); + +app.UseForwardedHeaders(new ForwardedHeadersOptions +{ + ForwardedHeaders = ForwardedHeaders.All +}); + +app.MapControllers(); + +app.Run(); diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/AccountOwnerServer/Properties/launchSettings.json b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/AccountOwnerServer/Properties/launchSettings.json new file mode 100644 index 000000000..a82b9223a --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/AccountOwnerServer/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "AccountOwnerServer": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "api/owners", + "applicationUrl": "https://localhost:5001;http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/AccountOwnerServer/appsettings.Development.json b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/AccountOwnerServer/appsettings.Development.json new file mode 100644 index 000000000..e203e9407 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/AccountOwnerServer/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + } + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/AccountOwnerServer/appsettings.json b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/AccountOwnerServer/appsettings.json new file mode 100644 index 000000000..0096cb3d4 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/AccountOwnerServer/appsettings.json @@ -0,0 +1,11 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning" + } + }, + "sqlconnection": { + "connectionString": "Server=(localdb)\\mssqllocaldb;Database=AccountOwner;Trusted_Connection=True;TrustServerCertificate=True;" + }, + "AllowedHosts": "*" +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/AccountOwnerServer/nlog.config b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/AccountOwnerServer/nlog.config new file mode 100644 index 000000000..52f675aa6 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/AccountOwnerServer/nlog.config @@ -0,0 +1,17 @@ + + + + + + + + + + + diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Contracts/Contracts.csproj b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Contracts/Contracts.csproj new file mode 100644 index 000000000..b201bbf27 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Contracts/Contracts.csproj @@ -0,0 +1,12 @@ + + + + net10.0 + enable + + + + + + + diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Contracts/IAccountRepository.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Contracts/IAccountRepository.cs new file mode 100644 index 000000000..80d10314b --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Contracts/IAccountRepository.cs @@ -0,0 +1,11 @@ +using Entities.Models; +using System; +using System.Collections.Generic; + +namespace Contracts; + +public interface IAccountRepository : IRepositoryBase +{ + IEnumerable GetAccountsByOwner(Guid ownerId); + Account? GetAccountByOwner(Guid ownerId, Guid id); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Contracts/ILoggerManager.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Contracts/ILoggerManager.cs new file mode 100644 index 000000000..42d30745d --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Contracts/ILoggerManager.cs @@ -0,0 +1,9 @@ +namespace Contracts; + +public interface ILoggerManager +{ + void LogInfo(string message); + void LogWarn(string message); + void LogDebug(string message); + void LogError(string message); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Contracts/IOwnerRepository.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Contracts/IOwnerRepository.cs new file mode 100644 index 000000000..9157da480 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Contracts/IOwnerRepository.cs @@ -0,0 +1,14 @@ +using Entities.Models; +using System; +using System.Collections.Generic; + +namespace Contracts; + +public interface IOwnerRepository : IRepositoryBase +{ + IEnumerable GetOwners(); + Owner GetOwnerById(Guid ownerId); + void CreateOwner(Owner owner); + void UpdateOwner(Owner dbOwner, Owner owner); + void DeleteOwner(Owner owner); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Contracts/IRepositoryBase.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Contracts/IRepositoryBase.cs new file mode 100644 index 000000000..51283872d --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Contracts/IRepositoryBase.cs @@ -0,0 +1,14 @@ +using System; +using System.Linq; +using System.Linq.Expressions; + +namespace Contracts; + +public interface IRepositoryBase +{ + IQueryable FindAll(); + IQueryable FindByCondition(Expression> expression); + void Create(T entity); + void Update(T entity); + void Delete(T entity); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Contracts/IRepositoryWrapper.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Contracts/IRepositoryWrapper.cs new file mode 100644 index 000000000..b7dd600fe --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Contracts/IRepositoryWrapper.cs @@ -0,0 +1,8 @@ +namespace Contracts; + +public interface IRepositoryWrapper +{ + IOwnerRepository Owner { get; } + IAccountRepository Account { get; } + void Save(); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Entities/Entities.csproj b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Entities/Entities.csproj new file mode 100644 index 000000000..ea034f6d3 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Entities/Entities.csproj @@ -0,0 +1,12 @@ + + + + net10.0 + enable + + + + + + + diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Entities/Enumerations/AccountType.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Entities/Enumerations/AccountType.cs new file mode 100644 index 000000000..b84e0ad48 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Entities/Enumerations/AccountType.cs @@ -0,0 +1,8 @@ +namespace Entities.Enumerations; + +public enum AccountType +{ + Domestic, + Savings, + Foreign +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Entities/Extensions/IEntityExtensions.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Entities/Extensions/IEntityExtensions.cs new file mode 100644 index 000000000..243eafc70 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Entities/Extensions/IEntityExtensions.cs @@ -0,0 +1,10 @@ +using System; + +namespace Entities.Extensions; + +public static class IEntityExtensions +{ + public static bool IsObjectNull(this IEntity? entity) => entity is null; + + public static bool IsEmptyObject(this IEntity entity) => entity.Id.Equals(Guid.Empty); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Entities/Extensions/OwnerExtensions.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Entities/Extensions/OwnerExtensions.cs new file mode 100644 index 000000000..ba2768266 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Entities/Extensions/OwnerExtensions.cs @@ -0,0 +1,13 @@ +using Entities.Models; + +namespace Entities.Extensions; + +public static class OwnerExtensions +{ + public static void Map(this Owner dbOwner, Owner owner) + { + dbOwner.Name = owner.Name; + dbOwner.Address = owner.Address; + dbOwner.DateOfBirth = owner.DateOfBirth; + } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Entities/IEntity.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Entities/IEntity.cs new file mode 100644 index 000000000..825a8525b --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Entities/IEntity.cs @@ -0,0 +1,8 @@ +using System; + +namespace Entities; + +public interface IEntity +{ + Guid Id { get; set; } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Entities/Models/Account.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Entities/Models/Account.cs new file mode 100644 index 000000000..7a5590d9f --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Entities/Models/Account.cs @@ -0,0 +1,22 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Entities.Models; + +[Table("account")] +public class Account : IEntity +{ + [Key] + [Column("AccountId")] + public Guid Id { get; set; } + + [Required(ErrorMessage = "Date created is required")] + public DateTime DateCreated { get; set; } + + [Required(ErrorMessage = "Account type is required")] + public string AccountType { get; set; } = string.Empty; + + [Required(ErrorMessage = "Owner Id is required")] + public Guid OwnerId { get; set; } +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Entities/Models/Owner.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Entities/Models/Owner.cs new file mode 100644 index 000000000..05b422d00 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Entities/Models/Owner.cs @@ -0,0 +1,24 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Entities.Models; + +[Table("owner")] +public class Owner : IEntity +{ + [Key] + [Column("OwnerId")] + public Guid Id { get; set; } + + [Required(ErrorMessage = "Name is required")] + [StringLength(60, ErrorMessage = "Name can't be longer than 60 characters")] + public string Name { get; set; } = string.Empty; + + [Required(ErrorMessage = "Date of birth is required")] + public DateTime DateOfBirth { get; set; } + + [Required(ErrorMessage = "Address is required")] + [StringLength(100, ErrorMessage = "Address can't be longer than 100 characters")] + public string Address { get; set; } = string.Empty; +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Entities/RepositoryContext.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Entities/RepositoryContext.cs new file mode 100644 index 000000000..1857e8e5f --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Entities/RepositoryContext.cs @@ -0,0 +1,15 @@ +using Entities.Models; +using Microsoft.EntityFrameworkCore; + +namespace Entities; + +public class RepositoryContext : DbContext +{ + public RepositoryContext(DbContextOptions options) + : base(options) + { + } + + public DbSet Owners { get; set; } = null!; + public DbSet Accounts { get; set; } = null!; +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/LoggerService/LoggerManager.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/LoggerService/LoggerManager.cs new file mode 100644 index 000000000..dd71d82d6 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/LoggerService/LoggerManager.cs @@ -0,0 +1,17 @@ +using Contracts; +using NLog; + +namespace LoggerService; + +public class LoggerManager : ILoggerManager +{ + private static readonly ILogger Logger = LogManager.GetCurrentClassLogger(); + + public void LogDebug(string message) => Logger.Debug(message); + + public void LogError(string message) => Logger.Error(message); + + public void LogInfo(string message) => Logger.Info(message); + + public void LogWarn(string message) => Logger.Warn(message); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/LoggerService/LoggerService.csproj b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/LoggerService/LoggerService.csproj new file mode 100644 index 000000000..8adf826f0 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/LoggerService/LoggerService.csproj @@ -0,0 +1,16 @@ + + + + net10.0 + enable + + + + + + + + + + + diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Repository/AccountRepository.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Repository/AccountRepository.cs new file mode 100644 index 000000000..657ed7bec --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Repository/AccountRepository.cs @@ -0,0 +1,22 @@ +using Contracts; +using Entities; +using Entities.Models; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Repository; + +public class AccountRepository : RepositoryBase, IAccountRepository +{ + public AccountRepository(RepositoryContext repositoryContext) + : base(repositoryContext) + { + } + + public IEnumerable GetAccountsByOwner(Guid ownerId) => + FindByCondition(a => a.OwnerId.Equals(ownerId)); + + public Account? GetAccountByOwner(Guid ownerId, Guid id) => + FindByCondition(a => a.OwnerId.Equals(ownerId) && a.Id.Equals(id)).SingleOrDefault(); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Repository/OwnerRepository.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Repository/OwnerRepository.cs new file mode 100644 index 000000000..9e5451abb --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Repository/OwnerRepository.cs @@ -0,0 +1,34 @@ +using Contracts; +using Entities; +using Entities.Extensions; +using Entities.Models; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Repository; + +public class OwnerRepository : RepositoryBase, IOwnerRepository +{ + public OwnerRepository(RepositoryContext repositoryContext) + : base(repositoryContext) + { + } + + public IEnumerable GetOwners() => + FindAll() + .OrderBy(o => o.Name); + + public Owner GetOwnerById(Guid ownerId) => + FindByCondition(owner => owner.Id.Equals(ownerId)).SingleOrDefault() ?? new Owner(); + + public void CreateOwner(Owner owner) => Create(owner); + + public void UpdateOwner(Owner dbOwner, Owner owner) + { + dbOwner.Map(owner); + Update(dbOwner); + } + + public void DeleteOwner(Owner owner) => Delete(owner); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Repository/Repository.csproj b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Repository/Repository.csproj new file mode 100644 index 000000000..61a67779d --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Repository/Repository.csproj @@ -0,0 +1,16 @@ + + + + net10.0 + enable + + + + + + + + + + + diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Repository/RepositoryBase.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Repository/RepositoryBase.cs new file mode 100644 index 000000000..51053f279 --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Repository/RepositoryBase.cs @@ -0,0 +1,33 @@ +using Contracts; +using Entities; +using Microsoft.EntityFrameworkCore; +using System; +using System.Linq; +using System.Linq.Expressions; + +namespace Repository; + +public abstract class RepositoryBase : IRepositoryBase where T : class +{ + protected RepositoryContext RepositoryContext { get; set; } + + protected RepositoryBase(RepositoryContext repositoryContext) + { + RepositoryContext = repositoryContext; + } + + public IQueryable FindAll() => + RepositoryContext.Set() + .AsNoTracking(); + + public IQueryable FindByCondition(Expression> expression) => + RepositoryContext.Set() + .Where(expression) + .AsNoTracking(); + + public void Create(T entity) => RepositoryContext.Set().Add(entity); + + public void Update(T entity) => RepositoryContext.Set().Update(entity); + + public void Delete(T entity) => RepositoryContext.Set().Remove(entity); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Repository/RepositoryWrapper.cs b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Repository/RepositoryWrapper.cs new file mode 100644 index 000000000..653c4a87c --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/Repository/RepositoryWrapper.cs @@ -0,0 +1,22 @@ +using Contracts; +using Entities; + +namespace Repository; + +public class RepositoryWrapper : IRepositoryWrapper +{ + private readonly RepositoryContext _repoContext; + private IOwnerRepository? _owner; + private IAccountRepository? _account; + + public RepositoryWrapper(RepositoryContext repositoryContext) + { + _repoContext = repositoryContext; + } + + public IOwnerRepository Owner => _owner ??= new OwnerRepository(_repoContext); + + public IAccountRepository Account => _account ??= new AccountRepository(_repoContext); + + public void Save() => _repoContext.SaveChanges(); +} diff --git a/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/_SqlServer_Init_Script/init.sql b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/_SqlServer_Init_Script/init.sql new file mode 100644 index 000000000..f8232089c --- /dev/null +++ b/aspnetcore-webapi/AdvancedRestConceptsSeries/StartingProject/_SqlServer_Init_Script/init.sql @@ -0,0 +1,44 @@ +-- SQL Server schema and seed data for the Advanced REST Concepts sample. +-- Run it against a database of your choice, then point sqlconnection:connectionString at it. +-- On Windows the default connection string uses LocalDB. Elsewhere, one line gets you a server: +-- docker run -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=Str0ng!Passw0rd" -p 1433:1433 -d mcr.microsoft.com/mssql/server:2022-latest + +IF OBJECT_ID('dbo.account', 'U') IS NOT NULL DROP TABLE dbo.account; +IF OBJECT_ID('dbo.owner', 'U') IS NOT NULL DROP TABLE dbo.owner; + +CREATE TABLE dbo.owner +( + OwnerId uniqueidentifier NOT NULL PRIMARY KEY, + Name nvarchar(60) NOT NULL, + DateOfBirth datetime2 NOT NULL, + Address nvarchar(100) NOT NULL +); + +CREATE TABLE dbo.account +( + AccountId uniqueidentifier NOT NULL PRIMARY KEY, + DateCreated datetime2 NOT NULL, + AccountType nvarchar(45) NOT NULL, + OwnerId uniqueidentifier NOT NULL + CONSTRAINT fk_account_owner REFERENCES dbo.owner (OwnerId) ON UPDATE CASCADE +); + +CREATE INDEX ix_owner_name ON dbo.owner (Name); +CREATE INDEX ix_owner_dateofbirth ON dbo.owner (DateOfBirth); + +INSERT INTO dbo.owner (OwnerId, Name, DateOfBirth, Address) VALUES + ('24fd81f8-d58a-4bcc-9f35-dc6cd5641906', 'John Keen', '1980-12-05', '61 Wellfield Road'), + ('261e1685-cf26-494c-b17c-3546e65f5620', 'Anna Bosh', '1974-11-14', '27 Colored Row'), + ('66774006-2371-4d5b-8518-2177bcf3f73e', 'Nick Somion', '1998-12-15', 'North sunny address 102'), + ('a3c1880c-674c-4d18-8f91-5d3608a2c937', 'Sam Query', '1990-04-22', '91 Western Roads'), + ('f98e4d74-0f68-4aac-89fd-047f1aaca6b6', 'Martin Miller', '1983-05-21', '3 Edgar Buildings'); + +INSERT INTO dbo.account (AccountId, DateCreated, AccountType, OwnerId) VALUES + ('03e91478-5608-4132-a753-d494dafce00b', '2003-12-15', 'Domestic', 'f98e4d74-0f68-4aac-89fd-047f1aaca6b6'), + ('356a5a9b-64bf-4de0-bc84-5395a1fdc9c4', '1996-02-15', 'Domestic', '261e1685-cf26-494c-b17c-3546e65f5620'), + ('371b93f2-f8c5-4a32-894a-fc672741aa5b', '1999-05-04', 'Domestic', '24fd81f8-d58a-4bcc-9f35-dc6cd5641906'), + ('670775db-ecc0-4b90-a9ab-37cd0d8e2801', '1999-12-21', 'Savings', '24fd81f8-d58a-4bcc-9f35-dc6cd5641906'), + ('a3fbad0b-7f48-4feb-8ac0-6d3bbc997bfc', '2010-05-28', 'Domestic', 'a3c1880c-674c-4d18-8f91-5d3608a2c937'), + ('aa15f658-04bb-4f73-82af-82db49d0fbef', '1999-05-12', 'Foreign', '24fd81f8-d58a-4bcc-9f35-dc6cd5641906'), + ('c6066eb0-53ca-43e1-97aa-3c2169eec659', '1996-02-16', 'Foreign', '261e1685-cf26-494c-b17c-3546e65f5620'), + ('eccadf79-85fe-402f-893c-32d3f03ed9b1', '2010-06-20', 'Foreign', 'a3c1880c-674c-4d18-8f91-5d3608a2c937');