├── .gitattributes ├── .github └── workflows │ └── dotnet.yml ├── .gitignore ├── MinimalApiArchitecture.sln ├── README.md ├── src ├── Angular │ └── ClientApp │ │ └── src │ │ └── app │ │ └── web-api-client.ts ├── Api │ ├── Api.cs │ ├── Api.csproj │ ├── DependencyConfig.cs │ ├── Extensions │ │ └── WebApplicationBuilderExtensions.cs │ ├── Program.cs │ ├── Properties │ │ └── launchSettings.json │ ├── appsettings.Development.json │ ├── appsettings.json │ ├── nswag.json │ └── wwwroot │ │ └── api │ │ └── specification.json ├── Application │ ├── Application.cs │ ├── Application.csproj │ ├── Common │ │ └── Behaviours │ │ │ ├── LoggingBehaviour.cs │ │ │ └── TransactionBehaviour.cs │ ├── DependencyConfig.cs │ ├── Domain │ │ ├── DomainEvent.cs │ │ ├── Entities │ │ │ ├── Category.cs │ │ │ └── Product.cs │ │ └── Events │ │ │ └── ProductUpdatePriceEvent.cs │ ├── Features │ │ ├── Categories │ │ │ └── Queries │ │ │ │ └── GetCategories.cs │ │ └── Products │ │ │ ├── Commands │ │ │ ├── CreateProduct.cs │ │ │ ├── DeleteProduct.cs │ │ │ └── UpdateProduct.cs │ │ │ ├── EventHandlers │ │ │ └── PriceChangedEventHandler.cs │ │ │ └── Queries │ │ │ └── GetProducts.cs │ ├── Helpers │ │ └── AppConstants.cs │ └── Infrastructure │ │ └── Persistence │ │ ├── ApiDbContext.cs │ │ ├── Configurations │ │ └── ProductConfiguration.cs │ │ └── Migrations │ │ ├── 20211106205642_FirstMigration.Designer.cs │ │ ├── 20211106205642_FirstMigration.cs │ │ ├── 20211108200956_AddedCategory.Designer.cs │ │ ├── 20211108200956_AddedCategory.cs │ │ └── ApiDbContextModelSnapshot.cs └── Blazor │ └── Services │ └── ApiClient.cs └── tests ├── Api.IntegrationTests ├── Api.IntegrationTests.csproj ├── ApiWebApplication.cs ├── Features │ └── ProductsModuleTests.cs └── TestBase.cs └── Application.Unit.Tests ├── Application.Unit.Tests.csproj ├── DbContextInMemoryFactory.cs ├── Domain └── Entities │ └── ProductTests.cs ├── Features └── Products │ └── EventHandlers │ └── PriceChangedEventHandlerTests.cs └── TestBase.cs /.gitattributes: -------------------------------------------------------------------------------- 1 | ############################################################################### 2 | # Set default behavior to automatically normalize line endings. 3 | ############################################################################### 4 | * text=auto 5 | 6 | ############################################################################### 7 | # Set default behavior for command prompt diff. 8 | # 9 | # This is need for earlier builds of msysgit that does not have it on by 10 | # default for csharp files. 11 | # Note: This is only used by command line 12 | ############################################################################### 13 | #*.cs diff=csharp 14 | 15 | ############################################################################### 16 | # Set the merge driver for project and solution files 17 | # 18 | # Merging from the command prompt will add diff markers to the files if there 19 | # are conflicts (Merging from VS is not affected by the settings below, in VS 20 | # the diff markers are never inserted). Diff markers may cause the following 21 | # file extensions to fail to load in VS. An alternative would be to treat 22 | # these files as binary and thus will always conflict and require user 23 | # intervention with every merge. To do so, just uncomment the entries below 24 | ############################################################################### 25 | #*.sln merge=binary 26 | #*.csproj merge=binary 27 | #*.vbproj merge=binary 28 | #*.vcxproj merge=binary 29 | #*.vcproj merge=binary 30 | #*.dbproj merge=binary 31 | #*.fsproj merge=binary 32 | #*.lsproj merge=binary 33 | #*.wixproj merge=binary 34 | #*.modelproj merge=binary 35 | #*.sqlproj merge=binary 36 | #*.wwaproj merge=binary 37 | 38 | ############################################################################### 39 | # behavior for image files 40 | # 41 | # image files are treated as binary by default. 42 | ############################################################################### 43 | #*.jpg binary 44 | #*.png binary 45 | #*.gif binary 46 | 47 | ############################################################################### 48 | # diff behavior for common document formats 49 | # 50 | # Convert binary document formats to text before diffing them. This feature 51 | # is only available from the command line. Turn it on by uncommenting the 52 | # entries below. 53 | ############################################################################### 54 | #*.doc diff=astextplain 55 | #*.DOC diff=astextplain 56 | #*.docx diff=astextplain 57 | #*.DOCX diff=astextplain 58 | #*.dot diff=astextplain 59 | #*.DOT diff=astextplain 60 | #*.pdf diff=astextplain 61 | #*.PDF diff=astextplain 62 | #*.rtf diff=astextplain 63 | #*.RTF diff=astextplain 64 | -------------------------------------------------------------------------------- /.github/workflows/dotnet.yml: -------------------------------------------------------------------------------- 1 | name: .NET 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: windows-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Setup .NET 17 | uses: actions/setup-dotnet@v1 18 | with: 19 | dotnet-version: 8.0.x 20 | include-prerelease: true 21 | - name: Restore dependencies 22 | run: dotnet restore 23 | - name: Build 24 | run: dotnet build --no-restore 25 | - name: Test 26 | run: dotnet test --no-build --verbosity normal 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Ww][Ii][Nn]32/ 27 | [Aa][Rr][Mm]/ 28 | [Aa][Rr][Mm]64/ 29 | bld/ 30 | [Bb]in/ 31 | [Oo]bj/ 32 | [Oo]ut/ 33 | [Ll]og/ 34 | [Ll]ogs/ 35 | 36 | # Visual Studio 2015/2017 cache/options directory 37 | .vs/ 38 | # Uncomment if you have tasks that create the project's static files in wwwroot 39 | #wwwroot/ 40 | 41 | # Visual Studio 2017 auto generated files 42 | Generated\ Files/ 43 | 44 | # MSTest test Results 45 | [Tt]est[Rr]esult*/ 46 | [Bb]uild[Ll]og.* 47 | 48 | # NUnit 49 | *.VisualState.xml 50 | TestResult.xml 51 | nunit-*.xml 52 | 53 | # Build Results of an ATL Project 54 | [Dd]ebugPS/ 55 | [Rr]eleasePS/ 56 | dlldata.c 57 | 58 | # Benchmark Results 59 | BenchmarkDotNet.Artifacts/ 60 | 61 | # .NET Core 62 | project.lock.json 63 | project.fragment.lock.json 64 | artifacts/ 65 | 66 | # ASP.NET Scaffolding 67 | ScaffoldingReadMe.txt 68 | 69 | # StyleCop 70 | StyleCopReport.xml 71 | 72 | # Files built by Visual Studio 73 | *_i.c 74 | *_p.c 75 | *_h.h 76 | *.ilk 77 | *.meta 78 | *.obj 79 | *.iobj 80 | *.pch 81 | *.pdb 82 | *.ipdb 83 | *.pgc 84 | *.pgd 85 | *.rsp 86 | *.sbr 87 | *.tlb 88 | *.tli 89 | *.tlh 90 | *.tmp 91 | *.tmp_proj 92 | *_wpftmp.csproj 93 | *.log 94 | *.vspscc 95 | *.vssscc 96 | .builds 97 | *.pidb 98 | *.svclog 99 | *.scc 100 | 101 | # Chutzpah Test files 102 | _Chutzpah* 103 | 104 | # Visual C++ cache files 105 | ipch/ 106 | *.aps 107 | *.ncb 108 | *.opendb 109 | *.opensdf 110 | *.sdf 111 | *.cachefile 112 | *.VC.db 113 | *.VC.VC.opendb 114 | 115 | # Visual Studio profiler 116 | *.psess 117 | *.vsp 118 | *.vspx 119 | *.sap 120 | 121 | # Visual Studio Trace Files 122 | *.e2e 123 | 124 | # TFS 2012 Local Workspace 125 | $tf/ 126 | 127 | # Guidance Automation Toolkit 128 | *.gpState 129 | 130 | # ReSharper is a .NET coding add-in 131 | _ReSharper*/ 132 | *.[Rr]e[Ss]harper 133 | *.DotSettings.user 134 | 135 | # TeamCity is a build add-in 136 | _TeamCity* 137 | 138 | # DotCover is a Code Coverage Tool 139 | *.dotCover 140 | 141 | # AxoCover is a Code Coverage Tool 142 | .axoCover/* 143 | !.axoCover/settings.json 144 | 145 | # Coverlet is a free, cross platform Code Coverage Tool 146 | coverage*.json 147 | coverage*.xml 148 | coverage*.info 149 | 150 | # Visual Studio code coverage results 151 | *.coverage 152 | *.coveragexml 153 | 154 | # NCrunch 155 | _NCrunch_* 156 | .*crunch*.local.xml 157 | nCrunchTemp_* 158 | 159 | # MightyMoose 160 | *.mm.* 161 | AutoTest.Net/ 162 | 163 | # Web workbench (sass) 164 | .sass-cache/ 165 | 166 | # Installshield output folder 167 | [Ee]xpress/ 168 | 169 | # DocProject is a documentation generator add-in 170 | DocProject/buildhelp/ 171 | DocProject/Help/*.HxT 172 | DocProject/Help/*.HxC 173 | DocProject/Help/*.hhc 174 | DocProject/Help/*.hhk 175 | DocProject/Help/*.hhp 176 | DocProject/Help/Html2 177 | DocProject/Help/html 178 | 179 | # Click-Once directory 180 | publish/ 181 | 182 | # Publish Web Output 183 | *.[Pp]ublish.xml 184 | *.azurePubxml 185 | # Note: Comment the next line if you want to checkin your web deploy settings, 186 | # but database connection strings (with potential passwords) will be unencrypted 187 | *.pubxml 188 | *.publishproj 189 | 190 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 191 | # checkin your Azure Web App publish settings, but sensitive information contained 192 | # in these scripts will be unencrypted 193 | PublishScripts/ 194 | 195 | # NuGet Packages 196 | *.nupkg 197 | # NuGet Symbol Packages 198 | *.snupkg 199 | # The packages folder can be ignored because of Package Restore 200 | **/[Pp]ackages/* 201 | # except build/, which is used as an MSBuild target. 202 | !**/[Pp]ackages/build/ 203 | # Uncomment if necessary however generally it will be regenerated when needed 204 | #!**/[Pp]ackages/repositories.config 205 | # NuGet v3's project.json files produces more ignorable files 206 | *.nuget.props 207 | *.nuget.targets 208 | 209 | # Microsoft Azure Build Output 210 | csx/ 211 | *.build.csdef 212 | 213 | # Microsoft Azure Emulator 214 | ecf/ 215 | rcf/ 216 | 217 | # Windows Store app package directories and files 218 | AppPackages/ 219 | BundleArtifacts/ 220 | Package.StoreAssociation.xml 221 | _pkginfo.txt 222 | *.appx 223 | *.appxbundle 224 | *.appxupload 225 | 226 | # Visual Studio cache files 227 | # files ending in .cache can be ignored 228 | *.[Cc]ache 229 | # but keep track of directories ending in .cache 230 | !?*.[Cc]ache/ 231 | 232 | # Others 233 | ClientBin/ 234 | ~$* 235 | *~ 236 | *.dbmdl 237 | *.dbproj.schemaview 238 | *.jfm 239 | *.pfx 240 | *.publishsettings 241 | orleans.codegen.cs 242 | 243 | # Including strong name files can present a security risk 244 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 245 | #*.snk 246 | 247 | # Since there are multiple workflows, uncomment next line to ignore bower_components 248 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 249 | #bower_components/ 250 | 251 | # RIA/Silverlight projects 252 | Generated_Code/ 253 | 254 | # Backup & report files from converting an old project file 255 | # to a newer Visual Studio version. Backup files are not needed, 256 | # because we have git ;-) 257 | _UpgradeReport_Files/ 258 | Backup*/ 259 | UpgradeLog*.XML 260 | UpgradeLog*.htm 261 | ServiceFabricBackup/ 262 | *.rptproj.bak 263 | 264 | # SQL Server files 265 | *.mdf 266 | *.ldf 267 | *.ndf 268 | 269 | # Business Intelligence projects 270 | *.rdl.data 271 | *.bim.layout 272 | *.bim_*.settings 273 | *.rptproj.rsuser 274 | *- [Bb]ackup.rdl 275 | *- [Bb]ackup ([0-9]).rdl 276 | *- [Bb]ackup ([0-9][0-9]).rdl 277 | 278 | # Microsoft Fakes 279 | FakesAssemblies/ 280 | 281 | # GhostDoc plugin setting file 282 | *.GhostDoc.xml 283 | 284 | # Node.js Tools for Visual Studio 285 | .ntvs_analysis.dat 286 | node_modules/ 287 | 288 | # Visual Studio 6 build log 289 | *.plg 290 | 291 | # Visual Studio 6 workspace options file 292 | *.opt 293 | 294 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 295 | *.vbw 296 | 297 | # Visual Studio LightSwitch build output 298 | **/*.HTMLClient/GeneratedArtifacts 299 | **/*.DesktopClient/GeneratedArtifacts 300 | **/*.DesktopClient/ModelManifest.xml 301 | **/*.Server/GeneratedArtifacts 302 | **/*.Server/ModelManifest.xml 303 | _Pvt_Extensions 304 | 305 | # Paket dependency manager 306 | .paket/paket.exe 307 | paket-files/ 308 | 309 | # FAKE - F# Make 310 | .fake/ 311 | 312 | # CodeRush personal settings 313 | .cr/personal 314 | 315 | # Python Tools for Visual Studio (PTVS) 316 | __pycache__/ 317 | *.pyc 318 | 319 | # Cake - Uncomment if you are using it 320 | # tools/** 321 | # !tools/packages.config 322 | 323 | # Tabs Studio 324 | *.tss 325 | 326 | # Telerik's JustMock configuration file 327 | *.jmconfig 328 | 329 | # BizTalk build output 330 | *.btp.cs 331 | *.btm.cs 332 | *.odx.cs 333 | *.xsd.cs 334 | 335 | # OpenCover UI analysis results 336 | OpenCover/ 337 | 338 | # Azure Stream Analytics local run output 339 | ASALocalRun/ 340 | 341 | # MSBuild Binary and Structured Log 342 | *.binlog 343 | 344 | # NVidia Nsight GPU debugger configuration file 345 | *.nvuser 346 | 347 | # MFractors (Xamarin productivity tool) working folder 348 | .mfractor/ 349 | 350 | # Local History for Visual Studio 351 | .localhistory/ 352 | 353 | # BeatPulse healthcheck temp database 354 | healthchecksdb 355 | 356 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 357 | MigrationBackup/ 358 | 359 | # Ionide (cross platform F# VS Code tools) working folder 360 | .ionide/ 361 | 362 | # Fody - auto-generated XML schema 363 | FodyWeavers.xsd 364 | /.idea/.idea.MinimalApiArchitecture/.idea 365 | -------------------------------------------------------------------------------- /MinimalApiArchitecture.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.0.31825.309 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{F4393023-56DA-4861-BD46-7281AA6615D3}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Api", "src\Api\Api.csproj", "{284BA662-309D-4CD4-8DCD-1858D534999E}" 9 | EndProject 10 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{A709DF0B-0470-4F7D-84D2-7B750952CC99}" 11 | EndProject 12 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Api.IntegrationTests", "tests\Api.IntegrationTests\Api.IntegrationTests.csproj", "{005CE599-279E-4070-A66B-08F3780DD50E}" 13 | EndProject 14 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{EFBE4DE4-C1F5-471F-A113-CABDF843CD2B}" 15 | ProjectSection(SolutionItems) = preProject 16 | .github\workflows\dotnet.yml = .github\workflows\dotnet.yml 17 | README.md = README.md 18 | EndProjectSection 19 | EndProject 20 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Application", "src\Application\Application.csproj", "{0374315B-7161-45DD-ABF8-56CD02A1EAEB}" 21 | EndProject 22 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Application.Unit.Tests", "tests\Application.Unit.Tests\Application.Unit.Tests.csproj", "{00C997D7-B459-4DD0-8A1F-CADE18BF2609}" 23 | EndProject 24 | Global 25 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 26 | Debug|Any CPU = Debug|Any CPU 27 | Release|Any CPU = Release|Any CPU 28 | EndGlobalSection 29 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 30 | {284BA662-309D-4CD4-8DCD-1858D534999E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 31 | {284BA662-309D-4CD4-8DCD-1858D534999E}.Debug|Any CPU.Build.0 = Debug|Any CPU 32 | {284BA662-309D-4CD4-8DCD-1858D534999E}.Release|Any CPU.ActiveCfg = Release|Any CPU 33 | {284BA662-309D-4CD4-8DCD-1858D534999E}.Release|Any CPU.Build.0 = Release|Any CPU 34 | {005CE599-279E-4070-A66B-08F3780DD50E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 35 | {005CE599-279E-4070-A66B-08F3780DD50E}.Debug|Any CPU.Build.0 = Debug|Any CPU 36 | {005CE599-279E-4070-A66B-08F3780DD50E}.Release|Any CPU.ActiveCfg = Release|Any CPU 37 | {005CE599-279E-4070-A66B-08F3780DD50E}.Release|Any CPU.Build.0 = Release|Any CPU 38 | {0374315B-7161-45DD-ABF8-56CD02A1EAEB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 39 | {0374315B-7161-45DD-ABF8-56CD02A1EAEB}.Debug|Any CPU.Build.0 = Debug|Any CPU 40 | {0374315B-7161-45DD-ABF8-56CD02A1EAEB}.Release|Any CPU.ActiveCfg = Release|Any CPU 41 | {0374315B-7161-45DD-ABF8-56CD02A1EAEB}.Release|Any CPU.Build.0 = Release|Any CPU 42 | {00C997D7-B459-4DD0-8A1F-CADE18BF2609}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 43 | {00C997D7-B459-4DD0-8A1F-CADE18BF2609}.Debug|Any CPU.Build.0 = Debug|Any CPU 44 | {00C997D7-B459-4DD0-8A1F-CADE18BF2609}.Release|Any CPU.ActiveCfg = Release|Any CPU 45 | {00C997D7-B459-4DD0-8A1F-CADE18BF2609}.Release|Any CPU.Build.0 = Release|Any CPU 46 | EndGlobalSection 47 | GlobalSection(SolutionProperties) = preSolution 48 | HideSolutionNode = FALSE 49 | EndGlobalSection 50 | GlobalSection(NestedProjects) = preSolution 51 | {284BA662-309D-4CD4-8DCD-1858D534999E} = {F4393023-56DA-4861-BD46-7281AA6615D3} 52 | {005CE599-279E-4070-A66B-08F3780DD50E} = {A709DF0B-0470-4F7D-84D2-7B750952CC99} 53 | {0374315B-7161-45DD-ABF8-56CD02A1EAEB} = {F4393023-56DA-4861-BD46-7281AA6615D3} 54 | {00C997D7-B459-4DD0-8A1F-CADE18BF2609} = {A709DF0B-0470-4F7D-84D2-7B750952CC99} 55 | EndGlobalSection 56 | GlobalSection(ExtensibilityGlobals) = postSolution 57 | SolutionGuid = {3CACA112-3997-48CE-B42C-608AC0836824} 58 | EndGlobalSection 59 | EndGlobal 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Minimal API Vertical Slice Architecture 2 | 3 | This project is an experiment trying to create a solution template with Minimal APIs and Carter. 4 | 5 | # Technologies and patterns used 6 | 7 | - API 8 | - [Minimal API with .NET 6](https://docs.microsoft.com/en-us/aspnet/core/?view=aspnetcore-6.0) and [Carter](https://github.com/CarterCommunity/Carter) 9 | - [Vertical Slice Architecture](https://jimmybogard.com/vertical-slice-architecture/) 10 | - CQRS with [MediatR](https://github.com/jbogard/MediatR) 11 | - [FluentValidation](https://fluentvalidation.net/) 12 | - [AutoMapper](https://automapper.org/) 13 | - [Entity Framework Core 6](https://docs.microsoft.com/en-us/ef/core/) 14 | - Swagger with Code generation using [NSwag](https://github.com/RicoSuter/NSwag) 15 | - Logging with [Serilog](https://github.com/serilog/serilog-aspnetcore) 16 | - [Decorator](https://refactoring.guru/design-patterns/decorator) pattern using PipelineBehaviors 17 | 18 | - Testing 19 | - [NUnit](https://nunit.org/) 20 | - [FluentAssertions](https://fluentassertions.com/) 21 | - [Respawn](https://github.com/jbogard/Respawn) 22 | 23 | - Angular 24 | - HttpClient generated with NSwag and OpenAPI definition 25 | - Simple CRUD 26 | 27 | - Blazor 28 | - HttpClient generated with NSwag and OpenAPI definition 29 | - Simple CRUD 30 | 31 | # Common design principles 32 | 33 | - Separation of concerns 34 | - Encapsulation 35 | - Explicit dependencies 36 | - Single responsibility 37 | - Persistence ignorance* 38 | 39 | 40 | # Getting started 41 | 42 | The easiest way to get started is using Visual Studio 2022 or installing the .NET 6 SDK with `dotnet run`. 43 | 44 | # Database Migrations 45 | 46 | To create a new migration with `dotnet-ef` you first need to locate your API folder and then write the following: 47 | ```bash 48 | dotnet ef migrations add --project ..\Application\ -o Infrastructure\Persistence\Migrations 49 | ``` 50 | 51 | 52 | Aldo, you need to update the database: 53 | ```bash 54 | dotnet ef database update 55 | ``` 56 | 57 | 58 | # Overview 59 | 60 | This project is an experiment trying to create a solution for Minimal APIs using Vertical Slice Architecture. 61 | 62 | If you think this is highly coupled, the important thing is to minimize coupling between slices, and maximize coupling in a slice; 63 | if you need to change something (e.g. switching Entity Framework for Dapper), you only need to change the affected 64 | slices and not a big file with all of the data access. 65 | 66 | 67 | If you want to learn more, the project is based on these resources: 68 | - [Choosing between using clean or vertical](https://www.reddit.com/r/dotnet/comments/lw13r2/choosing_between_using_cleanonion_or_vertical/) 69 | - [Restructuring to a Vertical Slice Architecture](https://codeopinion.com/restructuring-to-a-vertical-slice-architecture/#:~:text=With%20vertical%20slice%20architecture%2C%20you,size%20of%20the%20vertical%20slice.) 70 | - [Vertical Slice Architecture - Jimmy Bogard](https://www.youtube.com/watch?v=SUiWfhAhgQw&feature=emb_logo&ab_channel=NDCConferences) 71 | 72 | ## API 73 | 74 | Minimal API that only hosts the application and wires up all the dependencies 75 | 76 | ## Application 77 | 78 | This project contains all the core and infrastructure of the application. The intention is to separate the application by functionality instead of technical concerns. 79 | 80 | ### Domain 81 | 82 | This will contain all entities, enums, exceptions, interfaces, types, and logic specific to the domain layer (this layer is shared between all features). 83 | 84 | We can have domain events, enterprise logic, value objects, etc. This layer (or folder in this project) has the same purpose according with DDD. 85 | 86 | ### Infrastructure 87 | 88 | This layer contains classes for accessing external resources. These classes should be based on interfaces only if we need them for testing. For example, Entity Framework is testable, and repositories are not needed. 89 | But if external services are called, we should abstract these classes for easy testing. 90 | 91 | ### Features 92 | 93 | This folder contains all the "slices" of functionality, and each slice does not overlap with other slices. If you need to change something, you only change a portion of 94 | a slice or, if new features are needed, you add code in new files which saves you from modifying large files (like repositories or services). 95 | 96 | 97 | # Credits 98 | 99 | Inspired by: 100 | 101 | - [ContosoUniversityDotNetCore-Pages](https://github.com/jbogard/ContosoUniversityDotNetCore-Pages) by Jimmy Bogard 102 | - [CleanArchitecture](https://github.com/jasontaylordev/CleanArchitecture) by Jason Taylor 103 | - [Carter](https://github.com/CarterCommunity/Carter) by Carter Community -------------------------------------------------------------------------------- /src/Angular/ClientApp/src/app/web-api-client.ts: -------------------------------------------------------------------------------- 1 | //---------------------- 2 | // 3 | // Generated using the NSwag toolchain v14.0.2.0 (NJsonSchema v11.0.0.0 (Newtonsoft.Json v13.0.0.0)) (http://NSwag.org) 4 | // 5 | //---------------------- 6 | 7 | /* tslint:disable */ 8 | /* eslint-disable */ 9 | // ReSharper disable InconsistentNaming 10 | 11 | import { mergeMap as _observableMergeMap, catchError as _observableCatch } from 'rxjs/operators'; 12 | import { Observable, throwError as _observableThrow, of as _observableOf } from 'rxjs'; 13 | import { Injectable, Inject, Optional, InjectionToken } from '@angular/core'; 14 | import { HttpClient, HttpHeaders, HttpResponse, HttpResponseBase } from '@angular/common/http'; 15 | 16 | export const API_BASE_URL = new InjectionToken('API_BASE_URL'); 17 | 18 | export interface IClient { 19 | getProducts(): Observable; 20 | createProduct(command: CreateProductCommand): Observable; 21 | updateProduct(command: UpdateProductCommand): Observable; 22 | deleteProduct(productId: number): Observable; 23 | getCategories(): Observable; 24 | } 25 | 26 | @Injectable({ 27 | providedIn: 'root' 28 | }) 29 | export class Client implements IClient { 30 | private http: HttpClient; 31 | private baseUrl: string; 32 | protected jsonParseReviver: ((key: string, value: any) => any) | undefined = undefined; 33 | 34 | constructor(@Inject(HttpClient) http: HttpClient, @Optional() @Inject(API_BASE_URL) baseUrl?: string) { 35 | this.http = http; 36 | this.baseUrl = baseUrl ?? ""; 37 | } 38 | 39 | getProducts(): Observable { 40 | let url_ = this.baseUrl + "/api/products"; 41 | url_ = url_.replace(/[?&]$/, ""); 42 | 43 | let options_ : any = { 44 | observe: "response", 45 | responseType: "blob", 46 | headers: new HttpHeaders({ 47 | "Accept": "application/json" 48 | }) 49 | }; 50 | 51 | return this.http.request("get", url_, options_).pipe(_observableMergeMap((response_ : any) => { 52 | return this.processGetProducts(response_); 53 | })).pipe(_observableCatch((response_: any) => { 54 | if (response_ instanceof HttpResponseBase) { 55 | try { 56 | return this.processGetProducts(response_ as any); 57 | } catch (e) { 58 | return _observableThrow(e) as any as Observable; 59 | } 60 | } else 61 | return _observableThrow(response_) as any as Observable; 62 | })); 63 | } 64 | 65 | protected processGetProducts(response: HttpResponseBase): Observable { 66 | const status = response.status; 67 | const responseBlob = 68 | response instanceof HttpResponse ? response.body : 69 | (response as any).error instanceof Blob ? (response as any).error : undefined; 70 | 71 | let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} 72 | if (status === 200) { 73 | return blobToText(responseBlob).pipe(_observableMergeMap(_responseText => { 74 | let result200: any = null; 75 | let resultData200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver); 76 | if (Array.isArray(resultData200)) { 77 | result200 = [] as any; 78 | for (let item of resultData200) 79 | result200!.push(GetProductsResponse.fromJS(item)); 80 | } 81 | else { 82 | result200 = null; 83 | } 84 | return _observableOf(result200); 85 | })); 86 | } else if (status !== 200 && status !== 204) { 87 | return blobToText(responseBlob).pipe(_observableMergeMap(_responseText => { 88 | return throwException("An unexpected server error occurred.", status, _responseText, _headers); 89 | })); 90 | } 91 | return _observableOf(null as any); 92 | } 93 | 94 | createProduct(command: CreateProductCommand): Observable { 95 | let url_ = this.baseUrl + "/api/products"; 96 | url_ = url_.replace(/[?&]$/, ""); 97 | 98 | const content_ = JSON.stringify(command); 99 | 100 | let options_ : any = { 101 | body: content_, 102 | observe: "response", 103 | responseType: "blob", 104 | headers: new HttpHeaders({ 105 | "Content-Type": "application/json", 106 | }) 107 | }; 108 | 109 | return this.http.request("post", url_, options_).pipe(_observableMergeMap((response_ : any) => { 110 | return this.processCreateProduct(response_); 111 | })).pipe(_observableCatch((response_: any) => { 112 | if (response_ instanceof HttpResponseBase) { 113 | try { 114 | return this.processCreateProduct(response_ as any); 115 | } catch (e) { 116 | return _observableThrow(e) as any as Observable; 117 | } 118 | } else 119 | return _observableThrow(response_) as any as Observable; 120 | })); 121 | } 122 | 123 | protected processCreateProduct(response: HttpResponseBase): Observable { 124 | const status = response.status; 125 | const responseBlob = 126 | response instanceof HttpResponse ? response.body : 127 | (response as any).error instanceof Blob ? (response as any).error : undefined; 128 | 129 | let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} 130 | if (status === 400) { 131 | return blobToText(responseBlob).pipe(_observableMergeMap(_responseText => { 132 | let result400: any = null; 133 | let resultData400 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver); 134 | result400 = HttpValidationProblemDetails.fromJS(resultData400); 135 | return throwException("A server side error occurred.", status, _responseText, _headers, result400); 136 | })); 137 | } else if (status === 201) { 138 | return blobToText(responseBlob).pipe(_observableMergeMap(_responseText => { 139 | return _observableOf(null as any); 140 | })); 141 | } else if (status !== 200 && status !== 204) { 142 | return blobToText(responseBlob).pipe(_observableMergeMap(_responseText => { 143 | return throwException("An unexpected server error occurred.", status, _responseText, _headers); 144 | })); 145 | } 146 | return _observableOf(null as any); 147 | } 148 | 149 | updateProduct(command: UpdateProductCommand): Observable { 150 | let url_ = this.baseUrl + "/api/products"; 151 | url_ = url_.replace(/[?&]$/, ""); 152 | 153 | const content_ = JSON.stringify(command); 154 | 155 | let options_ : any = { 156 | body: content_, 157 | observe: "response", 158 | responseType: "blob", 159 | headers: new HttpHeaders({ 160 | "Content-Type": "application/json", 161 | }) 162 | }; 163 | 164 | return this.http.request("put", url_, options_).pipe(_observableMergeMap((response_ : any) => { 165 | return this.processUpdateProduct(response_); 166 | })).pipe(_observableCatch((response_: any) => { 167 | if (response_ instanceof HttpResponseBase) { 168 | try { 169 | return this.processUpdateProduct(response_ as any); 170 | } catch (e) { 171 | return _observableThrow(e) as any as Observable; 172 | } 173 | } else 174 | return _observableThrow(response_) as any as Observable; 175 | })); 176 | } 177 | 178 | protected processUpdateProduct(response: HttpResponseBase): Observable { 179 | const status = response.status; 180 | const responseBlob = 181 | response instanceof HttpResponse ? response.body : 182 | (response as any).error instanceof Blob ? (response as any).error : undefined; 183 | 184 | let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} 185 | if (status === 404) { 186 | return blobToText(responseBlob).pipe(_observableMergeMap(_responseText => { 187 | return throwException("A server side error occurred.", status, _responseText, _headers); 188 | })); 189 | } else if (status === 400) { 190 | return blobToText(responseBlob).pipe(_observableMergeMap(_responseText => { 191 | let result400: any = null; 192 | let resultData400 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver); 193 | result400 = HttpValidationProblemDetails.fromJS(resultData400); 194 | return throwException("A server side error occurred.", status, _responseText, _headers, result400); 195 | })); 196 | } else if (status !== 200 && status !== 204) { 197 | return blobToText(responseBlob).pipe(_observableMergeMap(_responseText => { 198 | return throwException("An unexpected server error occurred.", status, _responseText, _headers); 199 | })); 200 | } 201 | return _observableOf(null as any); 202 | } 203 | 204 | deleteProduct(productId: number): Observable { 205 | let url_ = this.baseUrl + "/api/products/{productId}"; 206 | if (productId === undefined || productId === null) 207 | throw new Error("The parameter 'productId' must be defined."); 208 | url_ = url_.replace("{productId}", encodeURIComponent("" + productId)); 209 | url_ = url_.replace(/[?&]$/, ""); 210 | 211 | let options_ : any = { 212 | observe: "response", 213 | responseType: "blob", 214 | headers: new HttpHeaders({ 215 | }) 216 | }; 217 | 218 | return this.http.request("delete", url_, options_).pipe(_observableMergeMap((response_ : any) => { 219 | return this.processDeleteProduct(response_); 220 | })).pipe(_observableCatch((response_: any) => { 221 | if (response_ instanceof HttpResponseBase) { 222 | try { 223 | return this.processDeleteProduct(response_ as any); 224 | } catch (e) { 225 | return _observableThrow(e) as any as Observable; 226 | } 227 | } else 228 | return _observableThrow(response_) as any as Observable; 229 | })); 230 | } 231 | 232 | protected processDeleteProduct(response: HttpResponseBase): Observable { 233 | const status = response.status; 234 | const responseBlob = 235 | response instanceof HttpResponse ? response.body : 236 | (response as any).error instanceof Blob ? (response as any).error : undefined; 237 | 238 | let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} 239 | if (status === 200) { 240 | return blobToText(responseBlob).pipe(_observableMergeMap(_responseText => { 241 | return _observableOf(null as any); 242 | })); 243 | } else if (status === 404) { 244 | return blobToText(responseBlob).pipe(_observableMergeMap(_responseText => { 245 | return throwException("A server side error occurred.", status, _responseText, _headers); 246 | })); 247 | } else if (status !== 200 && status !== 204) { 248 | return blobToText(responseBlob).pipe(_observableMergeMap(_responseText => { 249 | return throwException("An unexpected server error occurred.", status, _responseText, _headers); 250 | })); 251 | } 252 | return _observableOf(null as any); 253 | } 254 | 255 | getCategories(): Observable { 256 | let url_ = this.baseUrl + "/api/categories"; 257 | url_ = url_.replace(/[?&]$/, ""); 258 | 259 | let options_ : any = { 260 | observe: "response", 261 | responseType: "blob", 262 | headers: new HttpHeaders({ 263 | "Accept": "application/json" 264 | }) 265 | }; 266 | 267 | return this.http.request("get", url_, options_).pipe(_observableMergeMap((response_ : any) => { 268 | return this.processGetCategories(response_); 269 | })).pipe(_observableCatch((response_: any) => { 270 | if (response_ instanceof HttpResponseBase) { 271 | try { 272 | return this.processGetCategories(response_ as any); 273 | } catch (e) { 274 | return _observableThrow(e) as any as Observable; 275 | } 276 | } else 277 | return _observableThrow(response_) as any as Observable; 278 | })); 279 | } 280 | 281 | protected processGetCategories(response: HttpResponseBase): Observable { 282 | const status = response.status; 283 | const responseBlob = 284 | response instanceof HttpResponse ? response.body : 285 | (response as any).error instanceof Blob ? (response as any).error : undefined; 286 | 287 | let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} 288 | if (status === 200) { 289 | return blobToText(responseBlob).pipe(_observableMergeMap(_responseText => { 290 | let result200: any = null; 291 | let resultData200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver); 292 | if (Array.isArray(resultData200)) { 293 | result200 = [] as any; 294 | for (let item of resultData200) 295 | result200!.push(GetCategoriesResponse.fromJS(item)); 296 | } 297 | else { 298 | result200 = null; 299 | } 300 | return _observableOf(result200); 301 | })); 302 | } else if (status !== 200 && status !== 204) { 303 | return blobToText(responseBlob).pipe(_observableMergeMap(_responseText => { 304 | return throwException("An unexpected server error occurred.", status, _responseText, _headers); 305 | })); 306 | } 307 | return _observableOf(null as any); 308 | } 309 | } 310 | 311 | export class GetProductsResponse implements IGetProductsResponse { 312 | productId?: number; 313 | name?: string; 314 | description?: string; 315 | price?: number; 316 | categoryName?: string; 317 | 318 | constructor(data?: IGetProductsResponse) { 319 | if (data) { 320 | for (var property in data) { 321 | if (data.hasOwnProperty(property)) 322 | (this)[property] = (data)[property]; 323 | } 324 | } 325 | } 326 | 327 | init(_data?: any) { 328 | if (_data) { 329 | this.productId = _data["ProductId"]; 330 | this.name = _data["Name"]; 331 | this.description = _data["Description"]; 332 | this.price = _data["Price"]; 333 | this.categoryName = _data["CategoryName"]; 334 | } 335 | } 336 | 337 | static fromJS(data: any): GetProductsResponse { 338 | data = typeof data === 'object' ? data : {}; 339 | let result = new GetProductsResponse(); 340 | result.init(data); 341 | return result; 342 | } 343 | 344 | toJSON(data?: any) { 345 | data = typeof data === 'object' ? data : {}; 346 | data["ProductId"] = this.productId; 347 | data["Name"] = this.name; 348 | data["Description"] = this.description; 349 | data["Price"] = this.price; 350 | data["CategoryName"] = this.categoryName; 351 | return data; 352 | } 353 | } 354 | 355 | export interface IGetProductsResponse { 356 | productId?: number; 357 | name?: string; 358 | description?: string; 359 | price?: number; 360 | categoryName?: string; 361 | } 362 | 363 | export class ProblemDetails implements IProblemDetails { 364 | type?: string | undefined; 365 | title?: string | undefined; 366 | status?: number | undefined; 367 | detail?: string | undefined; 368 | instance?: string | undefined; 369 | 370 | [key: string]: any; 371 | 372 | constructor(data?: IProblemDetails) { 373 | if (data) { 374 | for (var property in data) { 375 | if (data.hasOwnProperty(property)) 376 | (this)[property] = (data)[property]; 377 | } 378 | } 379 | } 380 | 381 | init(_data?: any) { 382 | if (_data) { 383 | for (var property in _data) { 384 | if (_data.hasOwnProperty(property)) 385 | this[property] = _data[property]; 386 | } 387 | this.type = _data["Type"]; 388 | this.title = _data["Title"]; 389 | this.status = _data["Status"]; 390 | this.detail = _data["Detail"]; 391 | this.instance = _data["Instance"]; 392 | } 393 | } 394 | 395 | static fromJS(data: any): ProblemDetails { 396 | data = typeof data === 'object' ? data : {}; 397 | let result = new ProblemDetails(); 398 | result.init(data); 399 | return result; 400 | } 401 | 402 | toJSON(data?: any) { 403 | data = typeof data === 'object' ? data : {}; 404 | for (var property in this) { 405 | if (this.hasOwnProperty(property)) 406 | data[property] = this[property]; 407 | } 408 | data["Type"] = this.type; 409 | data["Title"] = this.title; 410 | data["Status"] = this.status; 411 | data["Detail"] = this.detail; 412 | data["Instance"] = this.instance; 413 | return data; 414 | } 415 | } 416 | 417 | export interface IProblemDetails { 418 | type?: string | undefined; 419 | title?: string | undefined; 420 | status?: number | undefined; 421 | detail?: string | undefined; 422 | instance?: string | undefined; 423 | 424 | [key: string]: any; 425 | } 426 | 427 | export class HttpValidationProblemDetails extends ProblemDetails implements IHttpValidationProblemDetails { 428 | errors?: { [key: string]: string[]; }; 429 | 430 | [key: string]: any; 431 | 432 | constructor(data?: IHttpValidationProblemDetails) { 433 | super(data); 434 | } 435 | 436 | init(_data?: any) { 437 | super.init(_data); 438 | if (_data) { 439 | for (var property in _data) { 440 | if (_data.hasOwnProperty(property)) 441 | this[property] = _data[property]; 442 | } 443 | if (_data["Errors"]) { 444 | this.errors = {} as any; 445 | for (let key in _data["Errors"]) { 446 | if (_data["Errors"].hasOwnProperty(key)) 447 | (this.errors)![key] = _data["Errors"][key] !== undefined ? _data["Errors"][key] : []; 448 | } 449 | } 450 | } 451 | } 452 | 453 | static fromJS(data: any): HttpValidationProblemDetails { 454 | data = typeof data === 'object' ? data : {}; 455 | let result = new HttpValidationProblemDetails(); 456 | result.init(data); 457 | return result; 458 | } 459 | 460 | toJSON(data?: any) { 461 | data = typeof data === 'object' ? data : {}; 462 | for (var property in this) { 463 | if (this.hasOwnProperty(property)) 464 | data[property] = this[property]; 465 | } 466 | if (this.errors) { 467 | data["Errors"] = {}; 468 | for (let key in this.errors) { 469 | if (this.errors.hasOwnProperty(key)) 470 | (data["Errors"])[key] = (this.errors)[key]; 471 | } 472 | } 473 | super.toJSON(data); 474 | return data; 475 | } 476 | } 477 | 478 | export interface IHttpValidationProblemDetails extends IProblemDetails { 479 | errors?: { [key: string]: string[]; }; 480 | 481 | [key: string]: any; 482 | } 483 | 484 | export class CreateProductCommand implements ICreateProductCommand { 485 | name?: string; 486 | description?: string; 487 | price?: number; 488 | categoryId?: number; 489 | 490 | constructor(data?: ICreateProductCommand) { 491 | if (data) { 492 | for (var property in data) { 493 | if (data.hasOwnProperty(property)) 494 | (this)[property] = (data)[property]; 495 | } 496 | } 497 | } 498 | 499 | init(_data?: any) { 500 | if (_data) { 501 | this.name = _data["Name"]; 502 | this.description = _data["Description"]; 503 | this.price = _data["Price"]; 504 | this.categoryId = _data["CategoryId"]; 505 | } 506 | } 507 | 508 | static fromJS(data: any): CreateProductCommand { 509 | data = typeof data === 'object' ? data : {}; 510 | let result = new CreateProductCommand(); 511 | result.init(data); 512 | return result; 513 | } 514 | 515 | toJSON(data?: any) { 516 | data = typeof data === 'object' ? data : {}; 517 | data["Name"] = this.name; 518 | data["Description"] = this.description; 519 | data["Price"] = this.price; 520 | data["CategoryId"] = this.categoryId; 521 | return data; 522 | } 523 | } 524 | 525 | export interface ICreateProductCommand { 526 | name?: string; 527 | description?: string; 528 | price?: number; 529 | categoryId?: number; 530 | } 531 | 532 | export class UpdateProductCommand implements IUpdateProductCommand { 533 | productId?: number; 534 | name?: string | undefined; 535 | description?: string | undefined; 536 | price?: number; 537 | categoryId?: number; 538 | 539 | constructor(data?: IUpdateProductCommand) { 540 | if (data) { 541 | for (var property in data) { 542 | if (data.hasOwnProperty(property)) 543 | (this)[property] = (data)[property]; 544 | } 545 | } 546 | } 547 | 548 | init(_data?: any) { 549 | if (_data) { 550 | this.productId = _data["ProductId"]; 551 | this.name = _data["Name"]; 552 | this.description = _data["Description"]; 553 | this.price = _data["Price"]; 554 | this.categoryId = _data["CategoryId"]; 555 | } 556 | } 557 | 558 | static fromJS(data: any): UpdateProductCommand { 559 | data = typeof data === 'object' ? data : {}; 560 | let result = new UpdateProductCommand(); 561 | result.init(data); 562 | return result; 563 | } 564 | 565 | toJSON(data?: any) { 566 | data = typeof data === 'object' ? data : {}; 567 | data["ProductId"] = this.productId; 568 | data["Name"] = this.name; 569 | data["Description"] = this.description; 570 | data["Price"] = this.price; 571 | data["CategoryId"] = this.categoryId; 572 | return data; 573 | } 574 | } 575 | 576 | export interface IUpdateProductCommand { 577 | productId?: number; 578 | name?: string | undefined; 579 | description?: string | undefined; 580 | price?: number; 581 | categoryId?: number; 582 | } 583 | 584 | export class GetCategoriesResponse implements IGetCategoriesResponse { 585 | categoryId?: number; 586 | name?: string | undefined; 587 | 588 | constructor(data?: IGetCategoriesResponse) { 589 | if (data) { 590 | for (var property in data) { 591 | if (data.hasOwnProperty(property)) 592 | (this)[property] = (data)[property]; 593 | } 594 | } 595 | } 596 | 597 | init(_data?: any) { 598 | if (_data) { 599 | this.categoryId = _data["CategoryId"]; 600 | this.name = _data["Name"]; 601 | } 602 | } 603 | 604 | static fromJS(data: any): GetCategoriesResponse { 605 | data = typeof data === 'object' ? data : {}; 606 | let result = new GetCategoriesResponse(); 607 | result.init(data); 608 | return result; 609 | } 610 | 611 | toJSON(data?: any) { 612 | data = typeof data === 'object' ? data : {}; 613 | data["CategoryId"] = this.categoryId; 614 | data["Name"] = this.name; 615 | return data; 616 | } 617 | } 618 | 619 | export interface IGetCategoriesResponse { 620 | categoryId?: number; 621 | name?: string | undefined; 622 | } 623 | 624 | export class SwaggerException extends Error { 625 | message: string; 626 | status: number; 627 | response: string; 628 | headers: { [key: string]: any; }; 629 | result: any; 630 | 631 | constructor(message: string, status: number, response: string, headers: { [key: string]: any; }, result: any) { 632 | super(); 633 | 634 | this.message = message; 635 | this.status = status; 636 | this.response = response; 637 | this.headers = headers; 638 | this.result = result; 639 | } 640 | 641 | protected isSwaggerException = true; 642 | 643 | static isSwaggerException(obj: any): obj is SwaggerException { 644 | return obj.isSwaggerException === true; 645 | } 646 | } 647 | 648 | function throwException(message: string, status: number, response: string, headers: { [key: string]: any; }, result?: any): Observable { 649 | if (result !== null && result !== undefined) 650 | return _observableThrow(result); 651 | else 652 | return _observableThrow(new SwaggerException(message, status, response, headers, null)); 653 | } 654 | 655 | function blobToText(blob: any): Observable { 656 | return new Observable((observer: any) => { 657 | if (!blob) { 658 | observer.next(""); 659 | observer.complete(); 660 | } else { 661 | let reader = new FileReader(); 662 | reader.onload = event => { 663 | observer.next((event.target as any).result); 664 | observer.complete(); 665 | }; 666 | reader.readAsText(blob); 667 | } 668 | }); 669 | } -------------------------------------------------------------------------------- /src/Api/Api.cs: -------------------------------------------------------------------------------- 1 | namespace MinimalApiArchitecture.Api; 2 | 3 | /// 4 | /// Dummy class for assembly scan 5 | /// 6 | public class Api 7 | { 8 | } -------------------------------------------------------------------------------- /src/Api/Api.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | MinimalApiArchitecture.Api 8 | 9 | 10 | 11 | 12 | 13 | all 14 | runtime; build; native; contentfiles; analyzers; buildtransitive 15 | 16 | 17 | 18 | all 19 | runtime; build; native; contentfiles; analyzers; buildtransitive 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/Api/DependencyConfig.cs: -------------------------------------------------------------------------------- 1 | using MinimalApiArchitecture.Application.Helpers; 2 | 3 | namespace MinimalApiArchitecture.Api; 4 | 5 | public static class DependencyConfig 6 | { 7 | public static IServiceCollection AddWebApiConfig(this IServiceCollection services) 8 | { 9 | services.AddCors(options => 10 | { 11 | options.AddPolicy(name: AppConstants.CorsPolicy, 12 | builder => { builder.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader(); }); 13 | }); 14 | 15 | services.AddEndpointsApiExplorer(); 16 | services.AddOpenApiDocument(c => 17 | { 18 | c.Title = "Minimal APIs"; 19 | c.Version = "v1"; 20 | }); 21 | 22 | 23 | return services; 24 | } 25 | 26 | public static WebApplication MapSwagger(this WebApplication app) 27 | { 28 | app.UseOpenApi(); 29 | app.UseSwaggerUi(settings => { settings.Path = "/api"; }); 30 | 31 | return app; 32 | } 33 | } -------------------------------------------------------------------------------- /src/Api/Extensions/WebApplicationBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | using Serilog; 2 | using Serilog.Events; 3 | 4 | namespace MinimalApiArchitecture.Api.Extensions; 5 | 6 | public static class WebApplicationBuilderExtensions 7 | { 8 | public static void AddSerilog(this ConfigureHostBuilder host) 9 | { 10 | host.UseSerilog(); 11 | 12 | Log.Logger = new LoggerConfiguration() 13 | .MinimumLevel.Override("Microsoft", LogEventLevel.Information) 14 | .Enrich.FromLogContext() 15 | .WriteTo.Console() 16 | .CreateLogger(); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Api/Program.cs: -------------------------------------------------------------------------------- 1 | using Carter; 2 | using MinimalApiArchitecture.Api; 3 | using MinimalApiArchitecture.Api.Extensions; 4 | using MinimalApiArchitecture.Application; 5 | using MinimalApiArchitecture.Application.Helpers; 6 | 7 | var builder = WebApplication.CreateBuilder(args); 8 | 9 | builder.Host.AddSerilog(); 10 | 11 | builder.Services.AddWebApiConfig(); 12 | builder.Services.AddApplicationCore(); 13 | builder.Services.AddPersistence(builder.Configuration); 14 | 15 | var app = builder.Build(); 16 | 17 | app.UseCors(AppConstants.CorsPolicy); 18 | app.UseStaticFiles(); 19 | app.MapSwagger(); 20 | app.MapCarter(); 21 | app.Run(); -------------------------------------------------------------------------------- /src/Api/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "iisSettings": { 3 | "windowsAuthentication": false, 4 | "anonymousAuthentication": true, 5 | "iisExpress": { 6 | "applicationUrl": "http://localhost:17838", 7 | "sslPort": 44391 8 | } 9 | }, 10 | "profiles": { 11 | "Api": { 12 | "commandName": "Project", 13 | "dotnetRunMessages": true, 14 | "launchBrowser": true, 15 | "applicationUrl": "https://localhost:7284;http://localhost:5265", 16 | "environmentVariables": { 17 | "ASPNETCORE_ENVIRONMENT": "Development" 18 | } 19 | }, 20 | "IIS Express": { 21 | "commandName": "IISExpress", 22 | "launchBrowser": true, 23 | "environmentVariables": { 24 | "ASPNETCORE_ENVIRONMENT": "Development" 25 | } 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /src/Api/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /src/Api/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "ConnectionStrings": { 3 | "Default": "Server=(localdb)\\mssqllocaldb;Database=MinimalApiArchitecture;Trusted_Connection=True;MultipleActiveResultSets=false" 4 | }, 5 | "Logging": { 6 | "LogLevel": { 7 | "Default": "Information", 8 | "Microsoft.AspNetCore": "Warning" 9 | } 10 | }, 11 | "AllowedHosts": "*" 12 | } -------------------------------------------------------------------------------- /src/Api/nswag.json: -------------------------------------------------------------------------------- 1 | { 2 | "runtime": "Net80", 3 | "defaultVariables": null, 4 | "documentGenerator": { 5 | "aspNetCoreToOpenApi": { 6 | "project": "Api.csproj", 7 | "msBuildProjectExtensionsPath": null, 8 | "configuration": null, 9 | "runtime": null, 10 | "targetFramework": null, 11 | "noBuild": true, 12 | "verbose": false, 13 | "workingDirectory": null, 14 | "requireParametersWithoutDefault": true, 15 | "apiGroupNames": null, 16 | "defaultPropertyNameHandling": "CamelCase", 17 | "defaultReferenceTypeNullHandling": "Null", 18 | "defaultDictionaryValueReferenceTypeNullHandling": "NotNull", 19 | "defaultResponseReferenceTypeNullHandling": "NotNull", 20 | "defaultEnumHandling": "Integer", 21 | "flattenInheritanceHierarchy": false, 22 | "generateKnownTypes": true, 23 | "generateEnumMappingDescription": false, 24 | "generateXmlObjects": false, 25 | "generateAbstractProperties": false, 26 | "generateAbstractSchemas": true, 27 | "ignoreObsoleteProperties": false, 28 | "allowReferencesWithProperties": false, 29 | "excludedTypeNames": [], 30 | "serviceHost": null, 31 | "serviceBasePath": null, 32 | "serviceSchemes": [], 33 | "infoTitle": "Minimal APIs", 34 | "infoDescription": null, 35 | "infoVersion": "1.0.0", 36 | "documentTemplate": null, 37 | "documentProcessorTypes": [], 38 | "operationProcessorTypes": [], 39 | "typeNameGeneratorType": null, 40 | "schemaNameGeneratorType": null, 41 | "contractResolverType": null, 42 | "serializerSettingsType": null, 43 | "useDocumentProvider": true, 44 | "documentName": "v1", 45 | "aspNetCoreEnvironment": null, 46 | "createWebHostBuilderMethod": null, 47 | "startupType": null, 48 | "allowNullableBodyParameters": true, 49 | "output": "wwwroot/api/specification.json", 50 | "outputType": "OpenApi3", 51 | "assemblyPaths": [], 52 | "assemblyConfig": null, 53 | "referencePaths": [], 54 | "useNuGetCache": false 55 | } 56 | }, 57 | "codeGenerators": { 58 | "openApiToTypeScriptClient": { 59 | "className": "{controller}Client", 60 | "moduleName": "", 61 | "namespace": "", 62 | "typeScriptVersion": 2.7, 63 | "template": "Angular", 64 | "promiseType": "Promise", 65 | "httpClass": "HttpClient", 66 | "withCredentials": false, 67 | "useSingletonProvider": true, 68 | "injectionTokenType": "InjectionToken", 69 | "rxJsVersion": 6.0, 70 | "dateTimeType": "Date", 71 | "nullValue": "Undefined", 72 | "generateClientClasses": true, 73 | "generateClientInterfaces": true, 74 | "generateOptionalParameters": false, 75 | "exportTypes": true, 76 | "wrapDtoExceptions": false, 77 | "exceptionClass": "SwaggerException", 78 | "clientBaseClass": null, 79 | "wrapResponses": false, 80 | "wrapResponseMethods": [], 81 | "generateResponseClasses": true, 82 | "responseClass": "SwaggerResponse", 83 | "protectedMethods": [], 84 | "configurationClass": null, 85 | "useTransformOptionsMethod": false, 86 | "useTransformResultMethod": false, 87 | "generateDtoTypes": true, 88 | "operationGenerationMode": "MultipleClientsFromOperationId", 89 | "markOptionalProperties": true, 90 | "generateCloneMethod": false, 91 | "typeStyle": "Class", 92 | "classTypes": [], 93 | "extendedClasses": [], 94 | "extensionCode": null, 95 | "generateDefaultValues": true, 96 | "excludedTypeNames": [], 97 | "excludedParameterNames": [], 98 | "handleReferences": false, 99 | "generateConstructorInterface": true, 100 | "convertConstructorInterfaceData": false, 101 | "importRequiredTypes": true, 102 | "useGetBaseUrlMethod": false, 103 | "baseUrlTokenName": "API_BASE_URL", 104 | "queryNullValue": "", 105 | "inlineNamedDictionaries": false, 106 | "inlineNamedAny": false, 107 | "templateDirectory": null, 108 | "typeNameGeneratorType": null, 109 | "propertyNameGeneratorType": null, 110 | "enumNameGeneratorType": null, 111 | "serviceHost": null, 112 | "serviceSchemes": null, 113 | "output": "../Angular/ClientApp/src/app/web-api-client.ts" 114 | }, 115 | "openApiToCSharpClient": { 116 | "generateClientClasses": true, 117 | "generateClientInterfaces": true, 118 | "generateDtoTypes": true, 119 | "injectHttpClient": true, 120 | "disposeHttpClient": true, 121 | "generateExceptionClasses": true, 122 | "exceptionClass": "SwaggerException", 123 | "wrapDtoExceptions": true, 124 | "useHttpClientCreationMethod": false, 125 | "httpClientType": "System.Net.Http.HttpClient", 126 | "useHttpRequestMessageCreationMethod": false, 127 | "useBaseUrl": true, 128 | "generateBaseUrlProperty": true, 129 | "generateSyncMethods": false, 130 | "exposeJsonSerializerSettings": false, 131 | "clientClassAccessModifier": "public", 132 | "typeAccessModifier": "public", 133 | "generateContractsOutput": false, 134 | "parameterDateTimeFormat": "s", 135 | "generateUpdateJsonSerializerSettingsMethod": true, 136 | "serializeTypeInformation": false, 137 | "queryNullValue": "", 138 | "className": "{controller}Client", 139 | "operationGenerationMode": "MultipleClientsFromOperationId", 140 | "generateOptionalParameters": false, 141 | "generateJsonMethods": true, 142 | "parameterArrayType": "System.Collections.Generic.IEnumerable", 143 | "parameterDictionaryType": "System.Collections.Generic.IDictionary", 144 | "responseArrayType": "System.Collections.Generic.ICollection", 145 | "responseDictionaryType": "System.Collections.Generic.Dictionary", 146 | "wrapResponses": false, 147 | "generateResponseClasses": true, 148 | "responseClass": "SwaggerResponse", 149 | "namespace": "MinimalApiArchitecture.Api", 150 | "requiredPropertiesMustBeDefined": true, 151 | "dateType": "System.DateTime", 152 | "dateTimeType": "System.DateTime", 153 | "timeType": "System.TimeSpan", 154 | "timeSpanType": "System.TimeSpan", 155 | "arrayType": "System.Collections.ObjectModel.ObservableCollection", 156 | "dictionaryType": "System.Collections.Generic.Dictionary", 157 | "arrayBaseType": "System.Collections.ObjectModel.ObservableCollection", 158 | "dictionaryBaseType": "System.Collections.Generic.Dictionary", 159 | "classStyle": "Inpc", 160 | "generateDefaultValues": true, 161 | "generateDataAnnotations": true, 162 | "excludedTypeNames": [], 163 | "handleReferences": false, 164 | "generateImmutableArrayProperties": false, 165 | "generateImmutableDictionaryProperties": false, 166 | "output": "../Blazor/Services/ApiClient.cs" 167 | } 168 | } 169 | 170 | } 171 | -------------------------------------------------------------------------------- /src/Api/wwwroot/api/specification.json: -------------------------------------------------------------------------------- 1 | { 2 | "x-generator": "NSwag v14.0.2.0 (NJsonSchema v11.0.0.0 (Newtonsoft.Json v13.0.0.0))", 3 | "openapi": "3.0.0", 4 | "info": { 5 | "title": "Minimal APIs", 6 | "version": "v1" 7 | }, 8 | "paths": { 9 | "/api/products": { 10 | "get": { 11 | "tags": [ 12 | "Product" 13 | ], 14 | "operationId": "GetProducts", 15 | "responses": { 16 | "200": { 17 | "description": "", 18 | "content": { 19 | "application/json": { 20 | "schema": { 21 | "type": "array", 22 | "items": { 23 | "$ref": "#/components/schemas/GetProductsResponse" 24 | } 25 | } 26 | } 27 | } 28 | } 29 | } 30 | }, 31 | "post": { 32 | "tags": [ 33 | "Product" 34 | ], 35 | "operationId": "CreateProduct", 36 | "requestBody": { 37 | "x-name": "command", 38 | "content": { 39 | "application/json": { 40 | "schema": { 41 | "$ref": "#/components/schemas/CreateProductCommand" 42 | } 43 | } 44 | }, 45 | "required": true, 46 | "x-position": 1 47 | }, 48 | "responses": { 49 | "400": { 50 | "description": "", 51 | "content": { 52 | "application/json": { 53 | "schema": { 54 | "$ref": "#/components/schemas/HttpValidationProblemDetails" 55 | } 56 | } 57 | } 58 | }, 59 | "201": { 60 | "description": "" 61 | } 62 | } 63 | }, 64 | "put": { 65 | "tags": [ 66 | "Product" 67 | ], 68 | "operationId": "UpdateProduct", 69 | "requestBody": { 70 | "x-name": "command", 71 | "content": { 72 | "application/json": { 73 | "schema": { 74 | "$ref": "#/components/schemas/UpdateProductCommand" 75 | } 76 | } 77 | }, 78 | "required": true, 79 | "x-position": 1 80 | }, 81 | "responses": { 82 | "404": { 83 | "description": "" 84 | }, 85 | "400": { 86 | "description": "", 87 | "content": { 88 | "application/json": { 89 | "schema": { 90 | "$ref": "#/components/schemas/HttpValidationProblemDetails" 91 | } 92 | } 93 | } 94 | } 95 | } 96 | } 97 | }, 98 | "/api/products/{productId}": { 99 | "delete": { 100 | "tags": [ 101 | "Product" 102 | ], 103 | "operationId": "DeleteProduct", 104 | "parameters": [ 105 | { 106 | "name": "productId", 107 | "in": "path", 108 | "required": true, 109 | "schema": { 110 | "type": "integer", 111 | "format": "int32" 112 | }, 113 | "x-position": 1 114 | } 115 | ], 116 | "responses": { 117 | "200": { 118 | "description": "" 119 | }, 120 | "404": { 121 | "description": "" 122 | } 123 | } 124 | } 125 | }, 126 | "/api/categories": { 127 | "get": { 128 | "tags": [ 129 | "Category" 130 | ], 131 | "operationId": "GetCategories", 132 | "responses": { 133 | "200": { 134 | "description": "", 135 | "content": { 136 | "application/json": { 137 | "schema": { 138 | "type": "array", 139 | "items": { 140 | "$ref": "#/components/schemas/GetCategoriesResponse" 141 | } 142 | } 143 | } 144 | } 145 | } 146 | } 147 | } 148 | } 149 | }, 150 | "components": { 151 | "schemas": { 152 | "GetProductsResponse": { 153 | "type": "object", 154 | "additionalProperties": false, 155 | "properties": { 156 | "ProductId": { 157 | "type": "integer", 158 | "format": "int32" 159 | }, 160 | "Name": { 161 | "type": "string" 162 | }, 163 | "Description": { 164 | "type": "string" 165 | }, 166 | "Price": { 167 | "type": "number", 168 | "format": "double" 169 | }, 170 | "CategoryName": { 171 | "type": "string" 172 | } 173 | } 174 | }, 175 | "HttpValidationProblemDetails": { 176 | "allOf": [ 177 | { 178 | "$ref": "#/components/schemas/ProblemDetails" 179 | }, 180 | { 181 | "type": "object", 182 | "additionalProperties": { 183 | "nullable": true 184 | }, 185 | "properties": { 186 | "Errors": { 187 | "type": "object", 188 | "additionalProperties": { 189 | "type": "array", 190 | "items": { 191 | "type": "string" 192 | } 193 | } 194 | } 195 | } 196 | } 197 | ] 198 | }, 199 | "ProblemDetails": { 200 | "type": "object", 201 | "additionalProperties": { 202 | "nullable": true 203 | }, 204 | "properties": { 205 | "Type": { 206 | "type": "string", 207 | "nullable": true 208 | }, 209 | "Title": { 210 | "type": "string", 211 | "nullable": true 212 | }, 213 | "Status": { 214 | "type": "integer", 215 | "format": "int32", 216 | "nullable": true 217 | }, 218 | "Detail": { 219 | "type": "string", 220 | "nullable": true 221 | }, 222 | "Instance": { 223 | "type": "string", 224 | "nullable": true 225 | } 226 | } 227 | }, 228 | "CreateProductCommand": { 229 | "type": "object", 230 | "additionalProperties": false, 231 | "properties": { 232 | "Name": { 233 | "type": "string" 234 | }, 235 | "Description": { 236 | "type": "string" 237 | }, 238 | "Price": { 239 | "type": "number", 240 | "format": "double" 241 | }, 242 | "CategoryId": { 243 | "type": "integer", 244 | "format": "int32" 245 | } 246 | } 247 | }, 248 | "UpdateProductCommand": { 249 | "type": "object", 250 | "additionalProperties": false, 251 | "properties": { 252 | "ProductId": { 253 | "type": "integer", 254 | "format": "int32" 255 | }, 256 | "Name": { 257 | "type": "string", 258 | "nullable": true 259 | }, 260 | "Description": { 261 | "type": "string", 262 | "nullable": true 263 | }, 264 | "Price": { 265 | "type": "number", 266 | "format": "double" 267 | }, 268 | "CategoryId": { 269 | "type": "integer", 270 | "format": "int32" 271 | } 272 | } 273 | }, 274 | "GetCategoriesResponse": { 275 | "type": "object", 276 | "additionalProperties": false, 277 | "properties": { 278 | "CategoryId": { 279 | "type": "integer", 280 | "format": "int32" 281 | }, 282 | "Name": { 283 | "type": "string", 284 | "nullable": true 285 | } 286 | } 287 | } 288 | } 289 | } 290 | } -------------------------------------------------------------------------------- /src/Application/Application.cs: -------------------------------------------------------------------------------- 1 | namespace MinimalApiArchitecture.Application; 2 | 3 | /// 4 | /// Dummy class for assembly scan 5 | /// 6 | public class Application 7 | { 8 | } -------------------------------------------------------------------------------- /src/Application/Application.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | MinimalApiArchitecture.Application 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | all 19 | runtime; build; native; contentfiles; analyzers; buildtransitive 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/Application/Common/Behaviours/LoggingBehaviour.cs: -------------------------------------------------------------------------------- 1 | using MediatR.Pipeline; 2 | using Microsoft.Extensions.Logging; 3 | 4 | namespace MinimalApiArchitecture.Application.Common.Behaviours; 5 | 6 | public class LoggingBehaviour(ILogger logger) : IRequestPreProcessor 7 | where TRequest : notnull 8 | { 9 | private readonly ILogger _logger = logger; 10 | 11 | public Task Process(TRequest request, CancellationToken cancellationToken) 12 | { 13 | var requestName = typeof(TRequest).Name; 14 | 15 | _logger.LogInformation("Minimal API Request: {Name} {@Request}", requestName, request); 16 | 17 | return Task.CompletedTask; 18 | } 19 | } 20 | 21 | -------------------------------------------------------------------------------- /src/Application/Common/Behaviours/TransactionBehaviour.cs: -------------------------------------------------------------------------------- 1 | using MediatR; 2 | using Microsoft.Extensions.Logging; 3 | using MinimalApiArchitecture.Application.Infrastructure.Persistence; 4 | 5 | namespace MinimalApiArchitecture.Application.Common.Behaviours 6 | { 7 | public class TransactionBehaviour( 8 | ApiDbContext context, 9 | ILogger> logger) 10 | : IPipelineBehavior 11 | where TRequest : IRequest 12 | { 13 | public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) 14 | { 15 | try 16 | { 17 | await context.BeginTransactionAsync(); 18 | var response = await next(); 19 | await context.CommitTransactionAsync(); 20 | 21 | return response; 22 | } 23 | catch (Exception) 24 | { 25 | logger.LogError("Request failed: Rolling back all the changes made to the Context"); 26 | 27 | await context.RollbackTransaction(); 28 | throw; 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Application/DependencyConfig.cs: -------------------------------------------------------------------------------- 1 | using Carter; 2 | using FluentValidation; 3 | using MediatR; 4 | using Microsoft.EntityFrameworkCore; 5 | using Microsoft.Extensions.Configuration; 6 | using Microsoft.Extensions.DependencyInjection; 7 | using MinimalApiArchitecture.Application.Common.Behaviours; 8 | using MinimalApiArchitecture.Application.Infrastructure.Persistence; 9 | 10 | namespace MinimalApiArchitecture.Application; 11 | 12 | public static class DependencyConfig 13 | { 14 | public static IServiceCollection AddApplicationCore(this IServiceCollection services) 15 | { 16 | services.AddCarter(); 17 | services.AddAutoMapper(typeof(Application)); 18 | services.AddMediatR(config => 19 | { 20 | config.RegisterServicesFromAssembly(typeof(Application).Assembly); 21 | config.AddOpenBehavior(typeof(TransactionBehaviour<,>)); 22 | }); 23 | services.AddValidatorsFromAssemblyContaining(typeof(Application)); 24 | 25 | return services; 26 | } 27 | 28 | public static IServiceCollection AddPersistence(this IServiceCollection services, IConfiguration config) 29 | { 30 | var connectionString = config.GetConnectionString("Default"); 31 | 32 | services.AddDbContext(options => 33 | options.UseSqlServer(connectionString)); 34 | 35 | return services; 36 | } 37 | } -------------------------------------------------------------------------------- /src/Application/Domain/DomainEvent.cs: -------------------------------------------------------------------------------- 1 | using MediatR; 2 | 3 | namespace MinimalApiArchitecture.Application.Domain; 4 | 5 | public interface IHasDomainEvent 6 | { 7 | public List DomainEvents { get; set; } 8 | } 9 | 10 | public abstract class DomainEvent : INotification 11 | { 12 | protected DomainEvent() 13 | { 14 | DateOccurred = DateTimeOffset.UtcNow; 15 | } 16 | 17 | public bool IsPublished { get; set; } 18 | 19 | // Using DateTimeOffset to ensure timezone information is preserved 20 | public DateTimeOffset DateOccurred { get; protected set; } = DateTimeOffset.UtcNow; 21 | } 22 | -------------------------------------------------------------------------------- /src/Application/Domain/Entities/Category.cs: -------------------------------------------------------------------------------- 1 | namespace MinimalApiArchitecture.Application.Domain.Entities; 2 | 3 | public class Category(int categoryId, string name) 4 | { 5 | public int CategoryId { get; set; } = categoryId; 6 | public string Name { get; set; } = name; 7 | 8 | public ICollection Products { get; set; } = 9 | new HashSet(); 10 | } -------------------------------------------------------------------------------- /src/Application/Domain/Entities/Product.cs: -------------------------------------------------------------------------------- 1 | using MinimalApiArchitecture.Application.Domain.Events; 2 | using MinimalApiArchitecture.Application.Features.Products.Commands; 3 | 4 | namespace MinimalApiArchitecture.Application.Domain.Entities; 5 | 6 | public class Product(int productId, string name, string description, double price, int categoryId) 7 | : IHasDomainEvent 8 | { 9 | public int ProductId { get; set; } = productId; 10 | public string Name { get; private set; } = name; 11 | public string Description { get; private set; } = description; 12 | public double Price { get; private set; } = price; 13 | public int CategoryId { get; private set; } = categoryId; 14 | public Category? Category { get; private set; } 15 | 16 | public List DomainEvents { get; set; } = new List(); 17 | 18 | public void UpdateInfo(UpdateProduct.UpdateProductCommand command) 19 | { 20 | if (Price != command.Price) 21 | { 22 | DomainEvents.Add(new ProductUpdatePriceEvent(this)); 23 | } 24 | 25 | Name = command.Name!; 26 | Description = command.Description!; 27 | Price = command.Price; 28 | CategoryId = command.CategoryId; 29 | } 30 | } -------------------------------------------------------------------------------- /src/Application/Domain/Events/ProductUpdatePriceEvent.cs: -------------------------------------------------------------------------------- 1 | using MinimalApiArchitecture.Application.Domain.Entities; 2 | 3 | namespace MinimalApiArchitecture.Application.Domain.Events 4 | { 5 | public class ProductUpdatePriceEvent(Product product) : DomainEvent 6 | { 7 | public Product Product { get; set; } = product; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/Application/Features/Categories/Queries/GetCategories.cs: -------------------------------------------------------------------------------- 1 | using AutoMapper; 2 | using AutoMapper.QueryableExtensions; 3 | using Carter; 4 | using MediatR; 5 | using Microsoft.AspNetCore.Builder; 6 | using Microsoft.AspNetCore.Http; 7 | using Microsoft.AspNetCore.Routing; 8 | using Microsoft.EntityFrameworkCore; 9 | using MinimalApiArchitecture.Application.Domain.Entities; 10 | using MinimalApiArchitecture.Application.Infrastructure.Persistence; 11 | 12 | namespace MinimalApiArchitecture.Application.Features.Categories.Queries; 13 | 14 | public class GetCategories : ICarterModule 15 | 16 | { 17 | public void AddRoutes(IEndpointRouteBuilder app) 18 | { 19 | app.MapGet("api/categories", (IMediator mediator) => 20 | { 21 | return mediator.Send(new GetCategoriesQuery()); 22 | }) 23 | .WithName(nameof(GetCategories)) 24 | .WithTags(nameof(Category)); 25 | } 26 | 27 | public class GetCategoriesQuery : IRequest> 28 | { 29 | 30 | } 31 | 32 | public class GetCategoriesHandler(ApiDbContext context, IMapper mapper) 33 | : IRequestHandler> 34 | { 35 | public Task> Handle(GetCategoriesQuery request, CancellationToken cancellationToken) => 36 | context.Categories.ProjectTo(mapper.ConfigurationProvider).ToListAsync(); 37 | } 38 | 39 | public class GetCategoriesResponse 40 | { 41 | public int CategoryId { get; set; } 42 | public string? Name { get; set; } 43 | } 44 | 45 | public class GetCategoriesMappingProfile : Profile 46 | { 47 | public GetCategoriesMappingProfile() => CreateMap(); 48 | } 49 | } -------------------------------------------------------------------------------- /src/Application/Features/Products/Commands/CreateProduct.cs: -------------------------------------------------------------------------------- 1 | using Carter; 2 | using Carter.ModelBinding; 3 | using FluentValidation; 4 | using MediatR; 5 | using Microsoft.AspNetCore.Builder; 6 | using Microsoft.AspNetCore.Http; 7 | using Microsoft.AspNetCore.Routing; 8 | using MinimalApiArchitecture.Application.Domain.Entities; 9 | using MinimalApiArchitecture.Application.Infrastructure.Persistence; 10 | 11 | namespace MinimalApiArchitecture.Application.Features.Products.Commands; 12 | 13 | public class CreateProduct : ICarterModule 14 | { 15 | public void AddRoutes(IEndpointRouteBuilder app) 16 | { 17 | app.MapPost("api/products", async (HttpRequest req, IMediator mediator, CreateProductCommand command) => 18 | { 19 | return await mediator.Send(command); 20 | }) 21 | .WithName(nameof(CreateProduct)) 22 | .WithTags(nameof(Product)) 23 | .ProducesValidationProblem() 24 | .Produces(StatusCodes.Status201Created); 25 | } 26 | 27 | public class CreateProductCommand : IRequest 28 | { 29 | public string Name { get; set; } = string.Empty; 30 | public string Description { get; set; } = string.Empty; 31 | public double Price { get; set; } 32 | public int CategoryId { get; set; } 33 | } 34 | 35 | public class CreateProductHandler(ApiDbContext context, IValidator validator) 36 | : IRequestHandler 37 | { 38 | public async Task Handle(CreateProductCommand request, CancellationToken cancellationToken) 39 | { 40 | var result = validator.Validate(request); 41 | if (!result.IsValid) 42 | { 43 | return Results.ValidationProblem(result.GetValidationProblems()); 44 | } 45 | 46 | var newProduct = new Product(0, request.Name, request.Description, request.Price, request.CategoryId); 47 | 48 | context.Products.Add(newProduct); 49 | 50 | await context.SaveChangesAsync(); 51 | 52 | return Results.Created($"api/products/{newProduct.ProductId}", null); 53 | } 54 | } 55 | 56 | public class CreateProductValidator : AbstractValidator 57 | { 58 | public CreateProductValidator() 59 | { 60 | RuleFor(r => r.Name).NotEmpty(); 61 | RuleFor(r => r.Description).NotEmpty(); 62 | RuleFor(r => r.Price).NotEmpty(); 63 | } 64 | } 65 | } -------------------------------------------------------------------------------- /src/Application/Features/Products/Commands/DeleteProduct.cs: -------------------------------------------------------------------------------- 1 | using Carter; 2 | using MediatR; 3 | using Microsoft.AspNetCore.Builder; 4 | using Microsoft.AspNetCore.Http; 5 | using Microsoft.AspNetCore.Routing; 6 | using MinimalApiArchitecture.Application.Domain.Entities; 7 | using MinimalApiArchitecture.Application.Infrastructure.Persistence; 8 | 9 | namespace MinimalApiArchitecture.Application.Features.Products.Commands; 10 | 11 | public class DeleteProduct : ICarterModule 12 | { 13 | public void AddRoutes(IEndpointRouteBuilder app) 14 | { 15 | app.MapDelete("api/products/{productId}", async (IMediator mediator, int productId) => 16 | { 17 | return await mediator.Send(new DeleteProductCommand(productId)); 18 | }) 19 | .WithName(nameof(DeleteProduct)) 20 | .WithTags(nameof(Product)) 21 | .Produces(StatusCodes.Status200OK) 22 | .Produces(StatusCodes.Status404NotFound); 23 | } 24 | 25 | public class DeleteProductCommand(int productId) : IRequest 26 | { 27 | public int ProductId { get; set; } = productId; 28 | } 29 | 30 | public class DeleteProductHandler(ApiDbContext context) : IRequestHandler 31 | { 32 | public async Task Handle(DeleteProductCommand request, CancellationToken cancellationToken) 33 | { 34 | var product = await context.Products.FindAsync(request.ProductId); 35 | 36 | if (product is null) 37 | { 38 | return Results.NotFound(); 39 | } 40 | 41 | context.Products.Remove(product); 42 | 43 | await context.SaveChangesAsync(); 44 | 45 | return Results.Ok(); 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /src/Application/Features/Products/Commands/UpdateProduct.cs: -------------------------------------------------------------------------------- 1 | using Carter; 2 | using Carter.ModelBinding; 3 | using FluentValidation; 4 | using MediatR; 5 | using Microsoft.AspNetCore.Builder; 6 | using Microsoft.AspNetCore.Http; 7 | using Microsoft.AspNetCore.Routing; 8 | using MinimalApiArchitecture.Application.Domain.Entities; 9 | using MinimalApiArchitecture.Application.Infrastructure.Persistence; 10 | 11 | namespace MinimalApiArchitecture.Application.Features.Products.Commands; 12 | 13 | public class UpdateProduct : ICarterModule 14 | { 15 | public void AddRoutes(IEndpointRouteBuilder app) 16 | { 17 | app.MapPut("api/products", async (IMediator mediator, UpdateProductCommand command) => 18 | { 19 | return await mediator.Send(command); 20 | }) 21 | .WithName(nameof(UpdateProduct)) 22 | .WithTags(nameof(Product)) 23 | .Produces(StatusCodes.Status404NotFound) 24 | .ProducesValidationProblem(); 25 | } 26 | 27 | public class UpdateProductCommand : IRequest 28 | { 29 | public int ProductId { get; set; } 30 | public string? Name { get; set; } 31 | public string? Description { get; set; } 32 | public double Price { get; set; } 33 | public int CategoryId { get; set; } 34 | } 35 | 36 | public class UpdateProductHandler(ApiDbContext context, IValidator validator) 37 | : IRequestHandler 38 | { 39 | public async Task Handle(UpdateProductCommand request, CancellationToken cancellationToken) 40 | { 41 | var result = validator.Validate(request); 42 | if (!result.IsValid) 43 | { 44 | return Results.ValidationProblem(result.GetValidationProblems()); 45 | } 46 | 47 | var product = await context.Products.FindAsync(request.ProductId); 48 | 49 | if (product is null) 50 | { 51 | return Results.NotFound(); 52 | } 53 | 54 | product.UpdateInfo(request); 55 | 56 | await context.SaveChangesAsync(); 57 | 58 | return Results.Ok(); 59 | } 60 | } 61 | 62 | public class UpdateProductValidator : AbstractValidator 63 | { 64 | public UpdateProductValidator() 65 | { 66 | RuleFor(r => r.ProductId).NotEmpty(); 67 | RuleFor(r => r.Name).NotEmpty(); 68 | RuleFor(r => r.Description).NotEmpty(); 69 | RuleFor(r => r.Price).NotEmpty(); 70 | } 71 | } 72 | } -------------------------------------------------------------------------------- /src/Application/Features/Products/EventHandlers/PriceChangedEventHandler.cs: -------------------------------------------------------------------------------- 1 | using MediatR; 2 | using Microsoft.Extensions.Logging; 3 | using MinimalApiArchitecture.Application.Domain.Events; 4 | 5 | namespace MinimalApiArchitecture.Application.Features.Products.EventHandlers 6 | { 7 | public class PriceChangedEventHandler(ILogger logger) 8 | : INotificationHandler 9 | { 10 | public Task Handle(ProductUpdatePriceEvent notification, CancellationToken cancellationToken) 11 | { 12 | logger.LogWarning("Minimal APIs Domain Event: {DomainEvent}", notification.GetType().Name); 13 | 14 | return Task.CompletedTask; 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Application/Features/Products/Queries/GetProducts.cs: -------------------------------------------------------------------------------- 1 | using AutoMapper; 2 | using AutoMapper.QueryableExtensions; 3 | using Carter; 4 | using MediatR; 5 | using Microsoft.AspNetCore.Builder; 6 | using Microsoft.AspNetCore.Http; 7 | using Microsoft.AspNetCore.Routing; 8 | using Microsoft.EntityFrameworkCore; 9 | using MinimalApiArchitecture.Application.Domain.Entities; 10 | using MinimalApiArchitecture.Application.Infrastructure.Persistence; 11 | 12 | namespace MinimalApiArchitecture.Application.Features.Products.Queries; 13 | 14 | public class GetProducts : ICarterModule 15 | { 16 | public void AddRoutes(IEndpointRouteBuilder app) 17 | { 18 | app.MapGet("api/products", (IMediator mediator) => 19 | { 20 | return mediator.Send(new GetProductsQuery()); 21 | }) 22 | .WithName(nameof(GetProducts)) 23 | .WithTags(nameof(Product)); 24 | 25 | } 26 | 27 | public class GetProductsQuery : IRequest> 28 | { 29 | 30 | } 31 | 32 | public class GetProductsHandler(ApiDbContext context, IMapper mapper) 33 | : IRequestHandler> 34 | { 35 | public Task> Handle(GetProductsQuery request, CancellationToken cancellationToken) => 36 | context.Products.ProjectTo(mapper.ConfigurationProvider).ToListAsync(); 37 | } 38 | 39 | public class GetProductsMappingProfile : Profile 40 | { 41 | public GetProductsMappingProfile() => CreateMap() 42 | .ForMember( 43 | d => d.CategoryName, 44 | opt => opt.MapFrom(mf => mf.Category != null ? mf.Category.Name : string.Empty) 45 | ); 46 | } 47 | 48 | public record GetProductsResponse(int ProductId, string Name, string Description, double Price, string CategoryName); 49 | } -------------------------------------------------------------------------------- /src/Application/Helpers/AppConstants.cs: -------------------------------------------------------------------------------- 1 | namespace MinimalApiArchitecture.Application.Helpers 2 | { 3 | public class AppConstants 4 | { 5 | public const string CorsPolicy = nameof(CorsPolicy); 6 | } 7 | } -------------------------------------------------------------------------------- /src/Application/Infrastructure/Persistence/ApiDbContext.cs: -------------------------------------------------------------------------------- 1 | using MediatR; 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.EntityFrameworkCore.Storage; 4 | using Microsoft.Extensions.Logging; 5 | using MinimalApiArchitecture.Application.Domain; 6 | using MinimalApiArchitecture.Application.Domain.Entities; 7 | 8 | namespace MinimalApiArchitecture.Application.Infrastructure.Persistence; 9 | 10 | public class ApiDbContext : DbContext 11 | { 12 | private readonly IPublisher _publisher; 13 | private readonly ILogger _logger; 14 | private IDbContextTransaction? _currentTransaction; 15 | 16 | public ApiDbContext(DbContextOptions options, IPublisher publisher, ILogger logger) : base(options) 17 | { 18 | _publisher = publisher; 19 | _logger = logger; 20 | 21 | _logger.LogDebug("DbContext created."); 22 | } 23 | 24 | public DbSet Categories => Set(); 25 | public DbSet Products => Set(); 26 | public async Task BeginTransactionAsync() 27 | { 28 | if (_currentTransaction is not null) 29 | { 30 | _logger.LogInformation("A transaction with ID {ID} is already created", _currentTransaction.TransactionId); 31 | return; 32 | } 33 | 34 | 35 | _currentTransaction = await Database.BeginTransactionAsync(); 36 | _logger.LogInformation("A new transaction was created with ID {ID}", _currentTransaction.TransactionId); 37 | } 38 | 39 | public async Task CommitTransactionAsync() 40 | { 41 | if (_currentTransaction is null) 42 | { 43 | return; 44 | } 45 | 46 | _logger.LogInformation("Commiting Transaction {ID}", _currentTransaction.TransactionId); 47 | 48 | await _currentTransaction.CommitAsync(); 49 | 50 | _currentTransaction.Dispose(); 51 | _currentTransaction = null; 52 | } 53 | 54 | public async Task RollbackTransaction() 55 | { 56 | if (_currentTransaction is null) 57 | { 58 | return; 59 | } 60 | 61 | _logger.LogDebug("Rolling back Transaction {ID}", _currentTransaction.TransactionId); 62 | 63 | await _currentTransaction.RollbackAsync(); 64 | 65 | _currentTransaction.Dispose(); 66 | _currentTransaction = null; 67 | } 68 | 69 | public override async Task SaveChangesAsync(CancellationToken cancellationToken = default) 70 | { 71 | var result = await base.SaveChangesAsync(cancellationToken); 72 | 73 | var events = ChangeTracker.Entries() 74 | .Select(x => x.Entity.DomainEvents) 75 | .SelectMany(x => x) 76 | .Where(domainEvent => !domainEvent.IsPublished) 77 | .ToArray(); 78 | 79 | foreach (var @event in events) 80 | { 81 | @event.IsPublished = true; 82 | 83 | _logger.LogInformation("New domain event {Event}", @event.GetType().Name); 84 | 85 | // Note: If an unhandled exception occurs, all the saved changes will be rolled back 86 | // by the TransactionBehavior. All the operations related to a domain event finish 87 | // successfully or none of them do. 88 | // Reference: https://docs.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/domain-events-design-implementation#what-is-a-domain-event 89 | await _publisher.Publish(@event); 90 | } 91 | 92 | return result; 93 | } 94 | 95 | protected override void OnModelCreating(ModelBuilder builder) 96 | { 97 | base.OnModelCreating(builder); 98 | 99 | builder.ApplyConfigurationsFromAssembly(typeof(ApiDbContext).Assembly); 100 | } 101 | } -------------------------------------------------------------------------------- /src/Application/Infrastructure/Persistence/Configurations/ProductConfiguration.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Microsoft.EntityFrameworkCore.Metadata.Builders; 3 | using MinimalApiArchitecture.Application.Domain.Entities; 4 | 5 | namespace MinimalApiArchitecture.Application.Infrastructure.Persistence.Configurations; 6 | 7 | public class ProductConfiguration : IEntityTypeConfiguration 8 | { 9 | public void Configure(EntityTypeBuilder builder) 10 | { 11 | builder.HasOne(x => x.Category) 12 | .WithMany(x => x.Products) 13 | .HasForeignKey(x => x.CategoryId); 14 | 15 | builder.Ignore(x => x.DomainEvents); 16 | } 17 | } -------------------------------------------------------------------------------- /src/Application/Infrastructure/Persistence/Migrations/20211106205642_FirstMigration.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore.Infrastructure; 5 | using Microsoft.EntityFrameworkCore.Metadata; 6 | using Microsoft.EntityFrameworkCore.Migrations; 7 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 8 | using MinimalApiArchitecture.Application.Infrastructure.Persistence; 9 | 10 | #nullable disable 11 | 12 | namespace MinimalApiArchitecture.Application.Infrastructure.Persistence.Migrations 13 | { 14 | [DbContext(typeof(ApiDbContext))] 15 | [Migration("20211106205642_FirstMigration")] 16 | partial class FirstMigration 17 | { 18 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 19 | { 20 | #pragma warning disable 612, 618 21 | modelBuilder 22 | .HasAnnotation("ProductVersion", "6.0.0-rc.2.21480.5") 23 | .HasAnnotation("Relational:MaxIdentifierLength", 128); 24 | 25 | SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder, 1L, 1); 26 | 27 | modelBuilder.Entity("Api.Entities.Product", b => 28 | { 29 | b.Property("ProductId") 30 | .ValueGeneratedOnAdd() 31 | .HasColumnType("int"); 32 | 33 | SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("ProductId"), 1L, 1); 34 | 35 | b.Property("Description") 36 | .IsRequired() 37 | .HasColumnType("nvarchar(max)"); 38 | 39 | b.Property("Name") 40 | .IsRequired() 41 | .HasColumnType("nvarchar(max)"); 42 | 43 | b.Property("Price") 44 | .HasColumnType("float"); 45 | 46 | b.HasKey("ProductId"); 47 | 48 | b.ToTable("Products"); 49 | }); 50 | #pragma warning restore 612, 618 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Application/Infrastructure/Persistence/Migrations/20211106205642_FirstMigration.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Migrations; 2 | 3 | #nullable disable 4 | 5 | namespace MinimalApiArchitecture.Application.Infrastructure.Persistence.Migrations 6 | { 7 | public partial class FirstMigration : Migration 8 | { 9 | protected override void Up(MigrationBuilder migrationBuilder) 10 | { 11 | migrationBuilder.CreateTable( 12 | name: "Products", 13 | columns: table => new 14 | { 15 | ProductId = table.Column(type: "int", nullable: false) 16 | .Annotation("SqlServer:Identity", "1, 1"), 17 | Name = table.Column(type: "nvarchar(max)", nullable: false), 18 | Description = table.Column(type: "nvarchar(max)", nullable: false), 19 | Price = table.Column(type: "float", nullable: false) 20 | }, 21 | constraints: table => 22 | { 23 | table.PrimaryKey("PK_Products", x => x.ProductId); 24 | }); 25 | } 26 | 27 | protected override void Down(MigrationBuilder migrationBuilder) 28 | { 29 | migrationBuilder.DropTable( 30 | name: "Products"); 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /src/Application/Infrastructure/Persistence/Migrations/20211108200956_AddedCategory.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.EntityFrameworkCore.Infrastructure; 4 | using Microsoft.EntityFrameworkCore.Metadata; 5 | using Microsoft.EntityFrameworkCore.Migrations; 6 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 7 | using MinimalApiArchitecture.Application.Infrastructure.Persistence; 8 | 9 | #nullable disable 10 | 11 | namespace MinimalApiArchitecture.Application.Infrastructure.Persistence.Migrations 12 | { 13 | [DbContext(typeof(ApiDbContext))] 14 | [Migration("20211108200956_AddedCategory")] 15 | partial class AddedCategory 16 | { 17 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 18 | { 19 | #pragma warning disable 612, 618 20 | modelBuilder 21 | .HasAnnotation("ProductVersion", "6.0.0") 22 | .HasAnnotation("Relational:MaxIdentifierLength", 128); 23 | 24 | SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder, 1L, 1); 25 | 26 | modelBuilder.Entity("MinimalApiArchitecture.Application.Entities.Category", b => 27 | { 28 | b.Property("CategoryId") 29 | .ValueGeneratedOnAdd() 30 | .HasColumnType("int"); 31 | 32 | SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("CategoryId"), 1L, 1); 33 | 34 | b.Property("Name") 35 | .IsRequired() 36 | .HasColumnType("nvarchar(max)"); 37 | 38 | b.HasKey("CategoryId"); 39 | 40 | b.ToTable("Categories"); 41 | }); 42 | 43 | modelBuilder.Entity("MinimalApiArchitecture.Application.Entities.Product", b => 44 | { 45 | b.Property("ProductId") 46 | .ValueGeneratedOnAdd() 47 | .HasColumnType("int"); 48 | 49 | SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("ProductId"), 1L, 1); 50 | 51 | b.Property("CategoryId") 52 | .HasColumnType("int"); 53 | 54 | b.Property("Description") 55 | .IsRequired() 56 | .HasColumnType("nvarchar(max)"); 57 | 58 | b.Property("Name") 59 | .IsRequired() 60 | .HasColumnType("nvarchar(max)"); 61 | 62 | b.Property("Price") 63 | .HasColumnType("float"); 64 | 65 | b.HasKey("ProductId"); 66 | 67 | b.HasIndex("CategoryId"); 68 | 69 | b.ToTable("Products"); 70 | }); 71 | 72 | modelBuilder.Entity("MinimalApiArchitecture.Application.Entities.Product", b => 73 | { 74 | b.HasOne("MinimalApiArchitecture.Application.Entities.Category", "Category") 75 | .WithMany("Products") 76 | .HasForeignKey("CategoryId") 77 | .OnDelete(DeleteBehavior.Cascade) 78 | .IsRequired(); 79 | 80 | b.Navigation("Category"); 81 | }); 82 | 83 | modelBuilder.Entity("MinimalApiArchitecture.Application.Entities.Category", b => 84 | { 85 | b.Navigation("Products"); 86 | }); 87 | #pragma warning restore 612, 618 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Application/Infrastructure/Persistence/Migrations/20211108200956_AddedCategory.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Migrations; 2 | 3 | #nullable disable 4 | 5 | namespace MinimalApiArchitecture.Application.Infrastructure.Persistence.Migrations 6 | { 7 | public partial class AddedCategory : Migration 8 | { 9 | protected override void Up(MigrationBuilder migrationBuilder) 10 | { 11 | migrationBuilder.AddColumn( 12 | name: "CategoryId", 13 | table: "Products", 14 | type: "int", 15 | nullable: false, 16 | defaultValue: 0); 17 | 18 | migrationBuilder.CreateTable( 19 | name: "Categories", 20 | columns: table => new 21 | { 22 | CategoryId = table.Column(type: "int", nullable: false) 23 | .Annotation("SqlServer:Identity", "1, 1"), 24 | Name = table.Column(type: "nvarchar(max)", nullable: false) 25 | }, 26 | constraints: table => 27 | { 28 | table.PrimaryKey("PK_Categories", x => x.CategoryId); 29 | }); 30 | 31 | migrationBuilder.CreateIndex( 32 | name: "IX_Products_CategoryId", 33 | table: "Products", 34 | column: "CategoryId"); 35 | 36 | migrationBuilder.AddForeignKey( 37 | name: "FK_Products_Categories_CategoryId", 38 | table: "Products", 39 | column: "CategoryId", 40 | principalTable: "Categories", 41 | principalColumn: "CategoryId", 42 | onDelete: ReferentialAction.Cascade); 43 | } 44 | 45 | protected override void Down(MigrationBuilder migrationBuilder) 46 | { 47 | migrationBuilder.DropForeignKey( 48 | name: "FK_Products_Categories_CategoryId", 49 | table: "Products"); 50 | 51 | migrationBuilder.DropTable( 52 | name: "Categories"); 53 | 54 | migrationBuilder.DropIndex( 55 | name: "IX_Products_CategoryId", 56 | table: "Products"); 57 | 58 | migrationBuilder.DropColumn( 59 | name: "CategoryId", 60 | table: "Products"); 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /src/Application/Infrastructure/Persistence/Migrations/ApiDbContextModelSnapshot.cs: -------------------------------------------------------------------------------- 1 | // 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.EntityFrameworkCore.Infrastructure; 4 | using Microsoft.EntityFrameworkCore.Metadata; 5 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 6 | using MinimalApiArchitecture.Application.Infrastructure.Persistence; 7 | 8 | #nullable disable 9 | 10 | namespace MinimalApiArchitecture.Application.Infrastructure.Persistence.Migrations 11 | { 12 | [DbContext(typeof(ApiDbContext))] 13 | partial class ApiDbContextModelSnapshot : ModelSnapshot 14 | { 15 | protected override void BuildModel(ModelBuilder modelBuilder) 16 | { 17 | #pragma warning disable 612, 618 18 | modelBuilder 19 | .HasAnnotation("ProductVersion", "6.0.0") 20 | .HasAnnotation("Relational:MaxIdentifierLength", 128); 21 | 22 | SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder, 1L, 1); 23 | 24 | modelBuilder.Entity("MinimalApiArchitecture.Application.Entities.Category", b => 25 | { 26 | b.Property("CategoryId") 27 | .ValueGeneratedOnAdd() 28 | .HasColumnType("int"); 29 | 30 | SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("CategoryId"), 1L, 1); 31 | 32 | b.Property("Name") 33 | .IsRequired() 34 | .HasColumnType("nvarchar(max)"); 35 | 36 | b.HasKey("CategoryId"); 37 | 38 | b.ToTable("Categories"); 39 | }); 40 | 41 | modelBuilder.Entity("MinimalApiArchitecture.Application.Entities.Product", b => 42 | { 43 | b.Property("ProductId") 44 | .ValueGeneratedOnAdd() 45 | .HasColumnType("int"); 46 | 47 | SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("ProductId"), 1L, 1); 48 | 49 | b.Property("CategoryId") 50 | .HasColumnType("int"); 51 | 52 | b.Property("Description") 53 | .IsRequired() 54 | .HasColumnType("nvarchar(max)"); 55 | 56 | b.Property("Name") 57 | .IsRequired() 58 | .HasColumnType("nvarchar(max)"); 59 | 60 | b.Property("Price") 61 | .HasColumnType("float"); 62 | 63 | b.HasKey("ProductId"); 64 | 65 | b.HasIndex("CategoryId"); 66 | 67 | b.ToTable("Products"); 68 | }); 69 | 70 | modelBuilder.Entity("MinimalApiArchitecture.Application.Entities.Product", b => 71 | { 72 | b.HasOne("MinimalApiArchitecture.Application.Entities.Category", "Category") 73 | .WithMany("Products") 74 | .HasForeignKey("CategoryId") 75 | .OnDelete(DeleteBehavior.Cascade) 76 | .IsRequired(); 77 | 78 | b.Navigation("Category"); 79 | }); 80 | 81 | modelBuilder.Entity("MinimalApiArchitecture.Application.Entities.Category", b => 82 | { 83 | b.Navigation("Products"); 84 | }); 85 | #pragma warning restore 612, 618 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/Blazor/Services/ApiClient.cs: -------------------------------------------------------------------------------- 1 | //---------------------- 2 | // 3 | // Generated using the NSwag toolchain v14.0.2.0 (NJsonSchema v11.0.0.0 (Newtonsoft.Json v13.0.0.0)) (http://NSwag.org) 4 | // 5 | //---------------------- 6 | 7 | #pragma warning disable 108 // Disable "CS0108 '{derivedDto}.ToJson()' hides inherited member '{dtoBase}.ToJson()'. Use the new keyword if hiding was intended." 8 | #pragma warning disable 114 // Disable "CS0114 '{derivedDto}.RaisePropertyChanged(String)' hides inherited member 'dtoBase.RaisePropertyChanged(String)'. To make the current member override that implementation, add the override keyword. Otherwise add the new keyword." 9 | #pragma warning disable 472 // Disable "CS0472 The result of the expression is always 'false' since a value of type 'Int32' is never equal to 'null' of type 'Int32?' 10 | #pragma warning disable 612 // Disable "CS0612 '...' is obsolete" 11 | #pragma warning disable 1573 // Disable "CS1573 Parameter '...' has no matching param tag in the XML comment for ... 12 | #pragma warning disable 1591 // Disable "CS1591 Missing XML comment for publicly visible type or member ..." 13 | #pragma warning disable 8073 // Disable "CS8073 The result of the expression is always 'false' since a value of type 'T' is never equal to 'null' of type 'T?'" 14 | #pragma warning disable 3016 // Disable "CS3016 Arrays as attribute arguments is not CLS-compliant" 15 | #pragma warning disable 8603 // Disable "CS8603 Possible null reference return" 16 | #pragma warning disable 8604 // Disable "CS8604 Possible null reference argument for parameter" 17 | #pragma warning disable 8625 // Disable "CS8625 Cannot convert null literal to non-nullable reference type" 18 | 19 | namespace MinimalApiArchitecture.Api 20 | { 21 | using System = global::System; 22 | 23 | [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.0.2.0 (NJsonSchema v11.0.0.0 (Newtonsoft.Json v13.0.0.0))")] 24 | public partial interface IClient 25 | { 26 | /// A server side error occurred. 27 | System.Threading.Tasks.Task> GetProductsAsync(); 28 | 29 | /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. 30 | /// A server side error occurred. 31 | System.Threading.Tasks.Task> GetProductsAsync(System.Threading.CancellationToken cancellationToken); 32 | 33 | /// A server side error occurred. 34 | System.Threading.Tasks.Task CreateProductAsync(CreateProductCommand command); 35 | 36 | /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. 37 | /// A server side error occurred. 38 | System.Threading.Tasks.Task CreateProductAsync(CreateProductCommand command, System.Threading.CancellationToken cancellationToken); 39 | 40 | /// A server side error occurred. 41 | System.Threading.Tasks.Task UpdateProductAsync(UpdateProductCommand command); 42 | 43 | /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. 44 | /// A server side error occurred. 45 | System.Threading.Tasks.Task UpdateProductAsync(UpdateProductCommand command, System.Threading.CancellationToken cancellationToken); 46 | 47 | /// A server side error occurred. 48 | System.Threading.Tasks.Task DeleteProductAsync(int productId); 49 | 50 | /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. 51 | /// A server side error occurred. 52 | System.Threading.Tasks.Task DeleteProductAsync(int productId, System.Threading.CancellationToken cancellationToken); 53 | 54 | /// A server side error occurred. 55 | System.Threading.Tasks.Task> GetCategoriesAsync(); 56 | 57 | /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. 58 | /// A server side error occurred. 59 | System.Threading.Tasks.Task> GetCategoriesAsync(System.Threading.CancellationToken cancellationToken); 60 | 61 | } 62 | 63 | [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.0.2.0 (NJsonSchema v11.0.0.0 (Newtonsoft.Json v13.0.0.0))")] 64 | public partial class Client : IClient 65 | { 66 | #pragma warning disable 8618 // Set by constructor via BaseUrl property 67 | private string _baseUrl; 68 | #pragma warning restore 8618 // Set by constructor via BaseUrl property 69 | private System.Net.Http.HttpClient _httpClient; 70 | private static System.Lazy _settings = new System.Lazy(CreateSerializerSettings, true); 71 | 72 | public Client(string baseUrl, System.Net.Http.HttpClient httpClient) 73 | { 74 | BaseUrl = baseUrl; 75 | _httpClient = httpClient; 76 | } 77 | 78 | private static Newtonsoft.Json.JsonSerializerSettings CreateSerializerSettings() 79 | { 80 | var settings = new Newtonsoft.Json.JsonSerializerSettings(); 81 | UpdateJsonSerializerSettings(settings); 82 | return settings; 83 | } 84 | 85 | public string BaseUrl 86 | { 87 | get { return _baseUrl; } 88 | set 89 | { 90 | _baseUrl = value; 91 | if (!string.IsNullOrEmpty(_baseUrl) && !_baseUrl.EndsWith("/")) 92 | _baseUrl += '/'; 93 | } 94 | } 95 | 96 | protected Newtonsoft.Json.JsonSerializerSettings JsonSerializerSettings { get { return _settings.Value; } } 97 | 98 | static partial void UpdateJsonSerializerSettings(Newtonsoft.Json.JsonSerializerSettings settings); 99 | 100 | partial void PrepareRequest(System.Net.Http.HttpClient client, System.Net.Http.HttpRequestMessage request, string url); 101 | partial void PrepareRequest(System.Net.Http.HttpClient client, System.Net.Http.HttpRequestMessage request, System.Text.StringBuilder urlBuilder); 102 | partial void ProcessResponse(System.Net.Http.HttpClient client, System.Net.Http.HttpResponseMessage response); 103 | 104 | /// A server side error occurred. 105 | public virtual System.Threading.Tasks.Task> GetProductsAsync() 106 | { 107 | return GetProductsAsync(System.Threading.CancellationToken.None); 108 | } 109 | 110 | /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. 111 | /// A server side error occurred. 112 | public virtual async System.Threading.Tasks.Task> GetProductsAsync(System.Threading.CancellationToken cancellationToken) 113 | { 114 | var client_ = _httpClient; 115 | var disposeClient_ = false; 116 | try 117 | { 118 | using (var request_ = new System.Net.Http.HttpRequestMessage()) 119 | { 120 | request_.Method = new System.Net.Http.HttpMethod("GET"); 121 | request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); 122 | 123 | var urlBuilder_ = new System.Text.StringBuilder(); 124 | if (!string.IsNullOrEmpty(BaseUrl)) urlBuilder_.Append(BaseUrl); 125 | // Operation Path: "api/products" 126 | urlBuilder_.Append("api/products"); 127 | 128 | PrepareRequest(client_, request_, urlBuilder_); 129 | 130 | var url_ = urlBuilder_.ToString(); 131 | request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); 132 | 133 | PrepareRequest(client_, request_, url_); 134 | 135 | var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); 136 | var disposeResponse_ = true; 137 | try 138 | { 139 | var headers_ = new System.Collections.Generic.Dictionary>(); 140 | foreach (var item_ in response_.Headers) 141 | headers_[item_.Key] = item_.Value; 142 | if (response_.Content != null && response_.Content.Headers != null) 143 | { 144 | foreach (var item_ in response_.Content.Headers) 145 | headers_[item_.Key] = item_.Value; 146 | } 147 | 148 | ProcessResponse(client_, response_); 149 | 150 | var status_ = (int)response_.StatusCode; 151 | if (status_ == 200) 152 | { 153 | var objectResponse_ = await ReadObjectResponseAsync>(response_, headers_, cancellationToken).ConfigureAwait(false); 154 | if (objectResponse_.Object == null) 155 | { 156 | throw new SwaggerException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); 157 | } 158 | return objectResponse_.Object; 159 | } 160 | else 161 | { 162 | var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); 163 | throw new SwaggerException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); 164 | } 165 | } 166 | finally 167 | { 168 | if (disposeResponse_) 169 | response_.Dispose(); 170 | } 171 | } 172 | } 173 | finally 174 | { 175 | if (disposeClient_) 176 | client_.Dispose(); 177 | } 178 | } 179 | 180 | /// A server side error occurred. 181 | public virtual System.Threading.Tasks.Task CreateProductAsync(CreateProductCommand command) 182 | { 183 | return CreateProductAsync(command, System.Threading.CancellationToken.None); 184 | } 185 | 186 | /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. 187 | /// A server side error occurred. 188 | public virtual async System.Threading.Tasks.Task CreateProductAsync(CreateProductCommand command, System.Threading.CancellationToken cancellationToken) 189 | { 190 | if (command == null) 191 | throw new System.ArgumentNullException("command"); 192 | 193 | var client_ = _httpClient; 194 | var disposeClient_ = false; 195 | try 196 | { 197 | using (var request_ = new System.Net.Http.HttpRequestMessage()) 198 | { 199 | var json_ = Newtonsoft.Json.JsonConvert.SerializeObject(command, _settings.Value); 200 | var content_ = new System.Net.Http.StringContent(json_); 201 | content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); 202 | request_.Content = content_; 203 | request_.Method = new System.Net.Http.HttpMethod("POST"); 204 | 205 | var urlBuilder_ = new System.Text.StringBuilder(); 206 | if (!string.IsNullOrEmpty(BaseUrl)) urlBuilder_.Append(BaseUrl); 207 | // Operation Path: "api/products" 208 | urlBuilder_.Append("api/products"); 209 | 210 | PrepareRequest(client_, request_, urlBuilder_); 211 | 212 | var url_ = urlBuilder_.ToString(); 213 | request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); 214 | 215 | PrepareRequest(client_, request_, url_); 216 | 217 | var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); 218 | var disposeResponse_ = true; 219 | try 220 | { 221 | var headers_ = new System.Collections.Generic.Dictionary>(); 222 | foreach (var item_ in response_.Headers) 223 | headers_[item_.Key] = item_.Value; 224 | if (response_.Content != null && response_.Content.Headers != null) 225 | { 226 | foreach (var item_ in response_.Content.Headers) 227 | headers_[item_.Key] = item_.Value; 228 | } 229 | 230 | ProcessResponse(client_, response_); 231 | 232 | var status_ = (int)response_.StatusCode; 233 | if (status_ == 400) 234 | { 235 | var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); 236 | if (objectResponse_.Object == null) 237 | { 238 | throw new SwaggerException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); 239 | } 240 | throw new SwaggerException("A server side error occurred.", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); 241 | } 242 | else 243 | if (status_ == 201) 244 | { 245 | return; 246 | } 247 | else 248 | { 249 | var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); 250 | throw new SwaggerException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); 251 | } 252 | } 253 | finally 254 | { 255 | if (disposeResponse_) 256 | response_.Dispose(); 257 | } 258 | } 259 | } 260 | finally 261 | { 262 | if (disposeClient_) 263 | client_.Dispose(); 264 | } 265 | } 266 | 267 | /// A server side error occurred. 268 | public virtual System.Threading.Tasks.Task UpdateProductAsync(UpdateProductCommand command) 269 | { 270 | return UpdateProductAsync(command, System.Threading.CancellationToken.None); 271 | } 272 | 273 | /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. 274 | /// A server side error occurred. 275 | public virtual async System.Threading.Tasks.Task UpdateProductAsync(UpdateProductCommand command, System.Threading.CancellationToken cancellationToken) 276 | { 277 | if (command == null) 278 | throw new System.ArgumentNullException("command"); 279 | 280 | var client_ = _httpClient; 281 | var disposeClient_ = false; 282 | try 283 | { 284 | using (var request_ = new System.Net.Http.HttpRequestMessage()) 285 | { 286 | var json_ = Newtonsoft.Json.JsonConvert.SerializeObject(command, _settings.Value); 287 | var content_ = new System.Net.Http.StringContent(json_); 288 | content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); 289 | request_.Content = content_; 290 | request_.Method = new System.Net.Http.HttpMethod("PUT"); 291 | 292 | var urlBuilder_ = new System.Text.StringBuilder(); 293 | if (!string.IsNullOrEmpty(BaseUrl)) urlBuilder_.Append(BaseUrl); 294 | // Operation Path: "api/products" 295 | urlBuilder_.Append("api/products"); 296 | 297 | PrepareRequest(client_, request_, urlBuilder_); 298 | 299 | var url_ = urlBuilder_.ToString(); 300 | request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); 301 | 302 | PrepareRequest(client_, request_, url_); 303 | 304 | var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); 305 | var disposeResponse_ = true; 306 | try 307 | { 308 | var headers_ = new System.Collections.Generic.Dictionary>(); 309 | foreach (var item_ in response_.Headers) 310 | headers_[item_.Key] = item_.Value; 311 | if (response_.Content != null && response_.Content.Headers != null) 312 | { 313 | foreach (var item_ in response_.Content.Headers) 314 | headers_[item_.Key] = item_.Value; 315 | } 316 | 317 | ProcessResponse(client_, response_); 318 | 319 | var status_ = (int)response_.StatusCode; 320 | if (status_ == 404) 321 | { 322 | string responseText_ = ( response_.Content == null ) ? string.Empty : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); 323 | throw new SwaggerException("A server side error occurred.", status_, responseText_, headers_, null); 324 | } 325 | else 326 | if (status_ == 400) 327 | { 328 | var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); 329 | if (objectResponse_.Object == null) 330 | { 331 | throw new SwaggerException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); 332 | } 333 | throw new SwaggerException("A server side error occurred.", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); 334 | } 335 | else 336 | 337 | if (status_ == 200 || status_ == 204) 338 | { 339 | 340 | return; 341 | } 342 | else 343 | { 344 | var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); 345 | throw new SwaggerException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); 346 | } 347 | } 348 | finally 349 | { 350 | if (disposeResponse_) 351 | response_.Dispose(); 352 | } 353 | } 354 | } 355 | finally 356 | { 357 | if (disposeClient_) 358 | client_.Dispose(); 359 | } 360 | } 361 | 362 | /// A server side error occurred. 363 | public virtual System.Threading.Tasks.Task DeleteProductAsync(int productId) 364 | { 365 | return DeleteProductAsync(productId, System.Threading.CancellationToken.None); 366 | } 367 | 368 | /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. 369 | /// A server side error occurred. 370 | public virtual async System.Threading.Tasks.Task DeleteProductAsync(int productId, System.Threading.CancellationToken cancellationToken) 371 | { 372 | if (productId == null) 373 | throw new System.ArgumentNullException("productId"); 374 | 375 | var client_ = _httpClient; 376 | var disposeClient_ = false; 377 | try 378 | { 379 | using (var request_ = new System.Net.Http.HttpRequestMessage()) 380 | { 381 | request_.Method = new System.Net.Http.HttpMethod("DELETE"); 382 | 383 | var urlBuilder_ = new System.Text.StringBuilder(); 384 | if (!string.IsNullOrEmpty(BaseUrl)) urlBuilder_.Append(BaseUrl); 385 | // Operation Path: "api/products/{productId}" 386 | urlBuilder_.Append("api/products/"); 387 | urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(productId, System.Globalization.CultureInfo.InvariantCulture))); 388 | 389 | PrepareRequest(client_, request_, urlBuilder_); 390 | 391 | var url_ = urlBuilder_.ToString(); 392 | request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); 393 | 394 | PrepareRequest(client_, request_, url_); 395 | 396 | var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); 397 | var disposeResponse_ = true; 398 | try 399 | { 400 | var headers_ = new System.Collections.Generic.Dictionary>(); 401 | foreach (var item_ in response_.Headers) 402 | headers_[item_.Key] = item_.Value; 403 | if (response_.Content != null && response_.Content.Headers != null) 404 | { 405 | foreach (var item_ in response_.Content.Headers) 406 | headers_[item_.Key] = item_.Value; 407 | } 408 | 409 | ProcessResponse(client_, response_); 410 | 411 | var status_ = (int)response_.StatusCode; 412 | if (status_ == 200) 413 | { 414 | return; 415 | } 416 | else 417 | if (status_ == 404) 418 | { 419 | string responseText_ = ( response_.Content == null ) ? string.Empty : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); 420 | throw new SwaggerException("A server side error occurred.", status_, responseText_, headers_, null); 421 | } 422 | else 423 | { 424 | var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); 425 | throw new SwaggerException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); 426 | } 427 | } 428 | finally 429 | { 430 | if (disposeResponse_) 431 | response_.Dispose(); 432 | } 433 | } 434 | } 435 | finally 436 | { 437 | if (disposeClient_) 438 | client_.Dispose(); 439 | } 440 | } 441 | 442 | /// A server side error occurred. 443 | public virtual System.Threading.Tasks.Task> GetCategoriesAsync() 444 | { 445 | return GetCategoriesAsync(System.Threading.CancellationToken.None); 446 | } 447 | 448 | /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. 449 | /// A server side error occurred. 450 | public virtual async System.Threading.Tasks.Task> GetCategoriesAsync(System.Threading.CancellationToken cancellationToken) 451 | { 452 | var client_ = _httpClient; 453 | var disposeClient_ = false; 454 | try 455 | { 456 | using (var request_ = new System.Net.Http.HttpRequestMessage()) 457 | { 458 | request_.Method = new System.Net.Http.HttpMethod("GET"); 459 | request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); 460 | 461 | var urlBuilder_ = new System.Text.StringBuilder(); 462 | if (!string.IsNullOrEmpty(BaseUrl)) urlBuilder_.Append(BaseUrl); 463 | // Operation Path: "api/categories" 464 | urlBuilder_.Append("api/categories"); 465 | 466 | PrepareRequest(client_, request_, urlBuilder_); 467 | 468 | var url_ = urlBuilder_.ToString(); 469 | request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); 470 | 471 | PrepareRequest(client_, request_, url_); 472 | 473 | var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); 474 | var disposeResponse_ = true; 475 | try 476 | { 477 | var headers_ = new System.Collections.Generic.Dictionary>(); 478 | foreach (var item_ in response_.Headers) 479 | headers_[item_.Key] = item_.Value; 480 | if (response_.Content != null && response_.Content.Headers != null) 481 | { 482 | foreach (var item_ in response_.Content.Headers) 483 | headers_[item_.Key] = item_.Value; 484 | } 485 | 486 | ProcessResponse(client_, response_); 487 | 488 | var status_ = (int)response_.StatusCode; 489 | if (status_ == 200) 490 | { 491 | var objectResponse_ = await ReadObjectResponseAsync>(response_, headers_, cancellationToken).ConfigureAwait(false); 492 | if (objectResponse_.Object == null) 493 | { 494 | throw new SwaggerException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); 495 | } 496 | return objectResponse_.Object; 497 | } 498 | else 499 | { 500 | var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); 501 | throw new SwaggerException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); 502 | } 503 | } 504 | finally 505 | { 506 | if (disposeResponse_) 507 | response_.Dispose(); 508 | } 509 | } 510 | } 511 | finally 512 | { 513 | if (disposeClient_) 514 | client_.Dispose(); 515 | } 516 | } 517 | 518 | protected struct ObjectResponseResult 519 | { 520 | public ObjectResponseResult(T responseObject, string responseText) 521 | { 522 | this.Object = responseObject; 523 | this.Text = responseText; 524 | } 525 | 526 | public T Object { get; } 527 | 528 | public string Text { get; } 529 | } 530 | 531 | public bool ReadResponseAsString { get; set; } 532 | 533 | protected virtual async System.Threading.Tasks.Task> ReadObjectResponseAsync(System.Net.Http.HttpResponseMessage response, System.Collections.Generic.IReadOnlyDictionary> headers, System.Threading.CancellationToken cancellationToken) 534 | { 535 | if (response == null || response.Content == null) 536 | { 537 | return new ObjectResponseResult(default(T), string.Empty); 538 | } 539 | 540 | if (ReadResponseAsString) 541 | { 542 | var responseText = await response.Content.ReadAsStringAsync().ConfigureAwait(false); 543 | try 544 | { 545 | var typedBody = Newtonsoft.Json.JsonConvert.DeserializeObject(responseText, JsonSerializerSettings); 546 | return new ObjectResponseResult(typedBody, responseText); 547 | } 548 | catch (Newtonsoft.Json.JsonException exception) 549 | { 550 | var message = "Could not deserialize the response body string as " + typeof(T).FullName + "."; 551 | throw new SwaggerException(message, (int)response.StatusCode, responseText, headers, exception); 552 | } 553 | } 554 | else 555 | { 556 | try 557 | { 558 | using (var responseStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false)) 559 | using (var streamReader = new System.IO.StreamReader(responseStream)) 560 | using (var jsonTextReader = new Newtonsoft.Json.JsonTextReader(streamReader)) 561 | { 562 | var serializer = Newtonsoft.Json.JsonSerializer.Create(JsonSerializerSettings); 563 | var typedBody = serializer.Deserialize(jsonTextReader); 564 | return new ObjectResponseResult(typedBody, string.Empty); 565 | } 566 | } 567 | catch (Newtonsoft.Json.JsonException exception) 568 | { 569 | var message = "Could not deserialize the response body stream as " + typeof(T).FullName + "."; 570 | throw new SwaggerException(message, (int)response.StatusCode, string.Empty, headers, exception); 571 | } 572 | } 573 | } 574 | 575 | private string ConvertToString(object value, System.Globalization.CultureInfo cultureInfo) 576 | { 577 | if (value == null) 578 | { 579 | return ""; 580 | } 581 | 582 | if (value is System.Enum) 583 | { 584 | var name = System.Enum.GetName(value.GetType(), value); 585 | if (name != null) 586 | { 587 | var field = System.Reflection.IntrospectionExtensions.GetTypeInfo(value.GetType()).GetDeclaredField(name); 588 | if (field != null) 589 | { 590 | var attribute = System.Reflection.CustomAttributeExtensions.GetCustomAttribute(field, typeof(System.Runtime.Serialization.EnumMemberAttribute)) 591 | as System.Runtime.Serialization.EnumMemberAttribute; 592 | if (attribute != null) 593 | { 594 | return attribute.Value != null ? attribute.Value : name; 595 | } 596 | } 597 | 598 | var converted = System.Convert.ToString(System.Convert.ChangeType(value, System.Enum.GetUnderlyingType(value.GetType()), cultureInfo)); 599 | return converted == null ? string.Empty : converted; 600 | } 601 | } 602 | else if (value is bool) 603 | { 604 | return System.Convert.ToString((bool)value, cultureInfo).ToLowerInvariant(); 605 | } 606 | else if (value is byte[]) 607 | { 608 | return System.Convert.ToBase64String((byte[]) value); 609 | } 610 | else if (value is string[]) 611 | { 612 | return string.Join(",", (string[])value); 613 | } 614 | else if (value.GetType().IsArray) 615 | { 616 | var valueArray = (System.Array)value; 617 | var valueTextArray = new string[valueArray.Length]; 618 | for (var i = 0; i < valueArray.Length; i++) 619 | { 620 | valueTextArray[i] = ConvertToString(valueArray.GetValue(i), cultureInfo); 621 | } 622 | return string.Join(",", valueTextArray); 623 | } 624 | 625 | var result = System.Convert.ToString(value, cultureInfo); 626 | return result == null ? "" : result; 627 | } 628 | } 629 | 630 | [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.0.2.0 (NJsonSchema v11.0.0.0 (Newtonsoft.Json v13.0.0.0))")] 631 | public partial class GetProductsResponse : System.ComponentModel.INotifyPropertyChanged 632 | { 633 | private int _productId; 634 | private string _name; 635 | private string _description; 636 | private double _price; 637 | private string _categoryName; 638 | 639 | [Newtonsoft.Json.JsonProperty("ProductId", Required = Newtonsoft.Json.Required.DisallowNull, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] 640 | public int ProductId 641 | { 642 | get { return _productId; } 643 | 644 | set 645 | { 646 | if (_productId != value) 647 | { 648 | _productId = value; 649 | RaisePropertyChanged(); 650 | } 651 | } 652 | } 653 | 654 | [Newtonsoft.Json.JsonProperty("Name", Required = Newtonsoft.Json.Required.DisallowNull, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] 655 | public string Name 656 | { 657 | get { return _name; } 658 | 659 | set 660 | { 661 | if (_name != value) 662 | { 663 | _name = value; 664 | RaisePropertyChanged(); 665 | } 666 | } 667 | } 668 | 669 | [Newtonsoft.Json.JsonProperty("Description", Required = Newtonsoft.Json.Required.DisallowNull, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] 670 | public string Description 671 | { 672 | get { return _description; } 673 | 674 | set 675 | { 676 | if (_description != value) 677 | { 678 | _description = value; 679 | RaisePropertyChanged(); 680 | } 681 | } 682 | } 683 | 684 | [Newtonsoft.Json.JsonProperty("Price", Required = Newtonsoft.Json.Required.DisallowNull, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] 685 | public double Price 686 | { 687 | get { return _price; } 688 | 689 | set 690 | { 691 | if (_price != value) 692 | { 693 | _price = value; 694 | RaisePropertyChanged(); 695 | } 696 | } 697 | } 698 | 699 | [Newtonsoft.Json.JsonProperty("CategoryName", Required = Newtonsoft.Json.Required.DisallowNull, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] 700 | public string CategoryName 701 | { 702 | get { return _categoryName; } 703 | 704 | set 705 | { 706 | if (_categoryName != value) 707 | { 708 | _categoryName = value; 709 | RaisePropertyChanged(); 710 | } 711 | } 712 | } 713 | 714 | public string ToJson() 715 | { 716 | 717 | return Newtonsoft.Json.JsonConvert.SerializeObject(this, new Newtonsoft.Json.JsonSerializerSettings()); 718 | 719 | } 720 | public static GetProductsResponse FromJson(string data) 721 | { 722 | 723 | return Newtonsoft.Json.JsonConvert.DeserializeObject(data, new Newtonsoft.Json.JsonSerializerSettings()); 724 | 725 | } 726 | 727 | public event System.ComponentModel.PropertyChangedEventHandler PropertyChanged; 728 | 729 | protected virtual void RaisePropertyChanged([System.Runtime.CompilerServices.CallerMemberName] string propertyName = null) 730 | { 731 | var handler = PropertyChanged; 732 | if (handler != null) 733 | handler(this, new System.ComponentModel.PropertyChangedEventArgs(propertyName)); 734 | } 735 | } 736 | 737 | [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.0.2.0 (NJsonSchema v11.0.0.0 (Newtonsoft.Json v13.0.0.0))")] 738 | public partial class HttpValidationProblemDetails : ProblemDetails 739 | { 740 | private System.Collections.Generic.Dictionary> _errors; 741 | 742 | [Newtonsoft.Json.JsonProperty("Errors", Required = Newtonsoft.Json.Required.DisallowNull, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] 743 | public System.Collections.Generic.Dictionary> Errors 744 | { 745 | get { return _errors; } 746 | 747 | set 748 | { 749 | if (_errors != value) 750 | { 751 | _errors = value; 752 | RaisePropertyChanged(); 753 | } 754 | } 755 | } 756 | 757 | public string ToJson() 758 | { 759 | 760 | return Newtonsoft.Json.JsonConvert.SerializeObject(this, new Newtonsoft.Json.JsonSerializerSettings()); 761 | 762 | } 763 | public static HttpValidationProblemDetails FromJson(string data) 764 | { 765 | 766 | return Newtonsoft.Json.JsonConvert.DeserializeObject(data, new Newtonsoft.Json.JsonSerializerSettings()); 767 | 768 | } 769 | 770 | } 771 | 772 | [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.0.2.0 (NJsonSchema v11.0.0.0 (Newtonsoft.Json v13.0.0.0))")] 773 | public partial class ProblemDetails : System.ComponentModel.INotifyPropertyChanged 774 | { 775 | private string _type; 776 | private string _title; 777 | private int? _status; 778 | private string _detail; 779 | private string _instance; 780 | 781 | [Newtonsoft.Json.JsonProperty("Type", Required = Newtonsoft.Json.Required.Default, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] 782 | public string Type 783 | { 784 | get { return _type; } 785 | 786 | set 787 | { 788 | if (_type != value) 789 | { 790 | _type = value; 791 | RaisePropertyChanged(); 792 | } 793 | } 794 | } 795 | 796 | [Newtonsoft.Json.JsonProperty("Title", Required = Newtonsoft.Json.Required.Default, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] 797 | public string Title 798 | { 799 | get { return _title; } 800 | 801 | set 802 | { 803 | if (_title != value) 804 | { 805 | _title = value; 806 | RaisePropertyChanged(); 807 | } 808 | } 809 | } 810 | 811 | [Newtonsoft.Json.JsonProperty("Status", Required = Newtonsoft.Json.Required.Default, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] 812 | public int? Status 813 | { 814 | get { return _status; } 815 | 816 | set 817 | { 818 | if (_status != value) 819 | { 820 | _status = value; 821 | RaisePropertyChanged(); 822 | } 823 | } 824 | } 825 | 826 | [Newtonsoft.Json.JsonProperty("Detail", Required = Newtonsoft.Json.Required.Default, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] 827 | public string Detail 828 | { 829 | get { return _detail; } 830 | 831 | set 832 | { 833 | if (_detail != value) 834 | { 835 | _detail = value; 836 | RaisePropertyChanged(); 837 | } 838 | } 839 | } 840 | 841 | [Newtonsoft.Json.JsonProperty("Instance", Required = Newtonsoft.Json.Required.Default, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] 842 | public string Instance 843 | { 844 | get { return _instance; } 845 | 846 | set 847 | { 848 | if (_instance != value) 849 | { 850 | _instance = value; 851 | RaisePropertyChanged(); 852 | } 853 | } 854 | } 855 | 856 | private System.Collections.Generic.IDictionary _additionalProperties; 857 | 858 | [Newtonsoft.Json.JsonExtensionData] 859 | public System.Collections.Generic.IDictionary AdditionalProperties 860 | { 861 | get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } 862 | set { _additionalProperties = value; } 863 | } 864 | 865 | public string ToJson() 866 | { 867 | 868 | return Newtonsoft.Json.JsonConvert.SerializeObject(this, new Newtonsoft.Json.JsonSerializerSettings()); 869 | 870 | } 871 | public static ProblemDetails FromJson(string data) 872 | { 873 | 874 | return Newtonsoft.Json.JsonConvert.DeserializeObject(data, new Newtonsoft.Json.JsonSerializerSettings()); 875 | 876 | } 877 | 878 | public event System.ComponentModel.PropertyChangedEventHandler PropertyChanged; 879 | 880 | protected virtual void RaisePropertyChanged([System.Runtime.CompilerServices.CallerMemberName] string propertyName = null) 881 | { 882 | var handler = PropertyChanged; 883 | if (handler != null) 884 | handler(this, new System.ComponentModel.PropertyChangedEventArgs(propertyName)); 885 | } 886 | } 887 | 888 | [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.0.2.0 (NJsonSchema v11.0.0.0 (Newtonsoft.Json v13.0.0.0))")] 889 | public partial class CreateProductCommand : System.ComponentModel.INotifyPropertyChanged 890 | { 891 | private string _name; 892 | private string _description; 893 | private double _price; 894 | private int _categoryId; 895 | 896 | [Newtonsoft.Json.JsonProperty("Name", Required = Newtonsoft.Json.Required.DisallowNull, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] 897 | public string Name 898 | { 899 | get { return _name; } 900 | 901 | set 902 | { 903 | if (_name != value) 904 | { 905 | _name = value; 906 | RaisePropertyChanged(); 907 | } 908 | } 909 | } 910 | 911 | [Newtonsoft.Json.JsonProperty("Description", Required = Newtonsoft.Json.Required.DisallowNull, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] 912 | public string Description 913 | { 914 | get { return _description; } 915 | 916 | set 917 | { 918 | if (_description != value) 919 | { 920 | _description = value; 921 | RaisePropertyChanged(); 922 | } 923 | } 924 | } 925 | 926 | [Newtonsoft.Json.JsonProperty("Price", Required = Newtonsoft.Json.Required.DisallowNull, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] 927 | public double Price 928 | { 929 | get { return _price; } 930 | 931 | set 932 | { 933 | if (_price != value) 934 | { 935 | _price = value; 936 | RaisePropertyChanged(); 937 | } 938 | } 939 | } 940 | 941 | [Newtonsoft.Json.JsonProperty("CategoryId", Required = Newtonsoft.Json.Required.DisallowNull, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] 942 | public int CategoryId 943 | { 944 | get { return _categoryId; } 945 | 946 | set 947 | { 948 | if (_categoryId != value) 949 | { 950 | _categoryId = value; 951 | RaisePropertyChanged(); 952 | } 953 | } 954 | } 955 | 956 | public string ToJson() 957 | { 958 | 959 | return Newtonsoft.Json.JsonConvert.SerializeObject(this, new Newtonsoft.Json.JsonSerializerSettings()); 960 | 961 | } 962 | public static CreateProductCommand FromJson(string data) 963 | { 964 | 965 | return Newtonsoft.Json.JsonConvert.DeserializeObject(data, new Newtonsoft.Json.JsonSerializerSettings()); 966 | 967 | } 968 | 969 | public event System.ComponentModel.PropertyChangedEventHandler PropertyChanged; 970 | 971 | protected virtual void RaisePropertyChanged([System.Runtime.CompilerServices.CallerMemberName] string propertyName = null) 972 | { 973 | var handler = PropertyChanged; 974 | if (handler != null) 975 | handler(this, new System.ComponentModel.PropertyChangedEventArgs(propertyName)); 976 | } 977 | } 978 | 979 | [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.0.2.0 (NJsonSchema v11.0.0.0 (Newtonsoft.Json v13.0.0.0))")] 980 | public partial class UpdateProductCommand : System.ComponentModel.INotifyPropertyChanged 981 | { 982 | private int _productId; 983 | private string _name; 984 | private string _description; 985 | private double _price; 986 | private int _categoryId; 987 | 988 | [Newtonsoft.Json.JsonProperty("ProductId", Required = Newtonsoft.Json.Required.DisallowNull, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] 989 | public int ProductId 990 | { 991 | get { return _productId; } 992 | 993 | set 994 | { 995 | if (_productId != value) 996 | { 997 | _productId = value; 998 | RaisePropertyChanged(); 999 | } 1000 | } 1001 | } 1002 | 1003 | [Newtonsoft.Json.JsonProperty("Name", Required = Newtonsoft.Json.Required.Default, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] 1004 | public string Name 1005 | { 1006 | get { return _name; } 1007 | 1008 | set 1009 | { 1010 | if (_name != value) 1011 | { 1012 | _name = value; 1013 | RaisePropertyChanged(); 1014 | } 1015 | } 1016 | } 1017 | 1018 | [Newtonsoft.Json.JsonProperty("Description", Required = Newtonsoft.Json.Required.Default, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] 1019 | public string Description 1020 | { 1021 | get { return _description; } 1022 | 1023 | set 1024 | { 1025 | if (_description != value) 1026 | { 1027 | _description = value; 1028 | RaisePropertyChanged(); 1029 | } 1030 | } 1031 | } 1032 | 1033 | [Newtonsoft.Json.JsonProperty("Price", Required = Newtonsoft.Json.Required.DisallowNull, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] 1034 | public double Price 1035 | { 1036 | get { return _price; } 1037 | 1038 | set 1039 | { 1040 | if (_price != value) 1041 | { 1042 | _price = value; 1043 | RaisePropertyChanged(); 1044 | } 1045 | } 1046 | } 1047 | 1048 | [Newtonsoft.Json.JsonProperty("CategoryId", Required = Newtonsoft.Json.Required.DisallowNull, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] 1049 | public int CategoryId 1050 | { 1051 | get { return _categoryId; } 1052 | 1053 | set 1054 | { 1055 | if (_categoryId != value) 1056 | { 1057 | _categoryId = value; 1058 | RaisePropertyChanged(); 1059 | } 1060 | } 1061 | } 1062 | 1063 | public string ToJson() 1064 | { 1065 | 1066 | return Newtonsoft.Json.JsonConvert.SerializeObject(this, new Newtonsoft.Json.JsonSerializerSettings()); 1067 | 1068 | } 1069 | public static UpdateProductCommand FromJson(string data) 1070 | { 1071 | 1072 | return Newtonsoft.Json.JsonConvert.DeserializeObject(data, new Newtonsoft.Json.JsonSerializerSettings()); 1073 | 1074 | } 1075 | 1076 | public event System.ComponentModel.PropertyChangedEventHandler PropertyChanged; 1077 | 1078 | protected virtual void RaisePropertyChanged([System.Runtime.CompilerServices.CallerMemberName] string propertyName = null) 1079 | { 1080 | var handler = PropertyChanged; 1081 | if (handler != null) 1082 | handler(this, new System.ComponentModel.PropertyChangedEventArgs(propertyName)); 1083 | } 1084 | } 1085 | 1086 | [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.0.2.0 (NJsonSchema v11.0.0.0 (Newtonsoft.Json v13.0.0.0))")] 1087 | public partial class GetCategoriesResponse : System.ComponentModel.INotifyPropertyChanged 1088 | { 1089 | private int _categoryId; 1090 | private string _name; 1091 | 1092 | [Newtonsoft.Json.JsonProperty("CategoryId", Required = Newtonsoft.Json.Required.DisallowNull, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] 1093 | public int CategoryId 1094 | { 1095 | get { return _categoryId; } 1096 | 1097 | set 1098 | { 1099 | if (_categoryId != value) 1100 | { 1101 | _categoryId = value; 1102 | RaisePropertyChanged(); 1103 | } 1104 | } 1105 | } 1106 | 1107 | [Newtonsoft.Json.JsonProperty("Name", Required = Newtonsoft.Json.Required.Default, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] 1108 | public string Name 1109 | { 1110 | get { return _name; } 1111 | 1112 | set 1113 | { 1114 | if (_name != value) 1115 | { 1116 | _name = value; 1117 | RaisePropertyChanged(); 1118 | } 1119 | } 1120 | } 1121 | 1122 | public string ToJson() 1123 | { 1124 | 1125 | return Newtonsoft.Json.JsonConvert.SerializeObject(this, new Newtonsoft.Json.JsonSerializerSettings()); 1126 | 1127 | } 1128 | public static GetCategoriesResponse FromJson(string data) 1129 | { 1130 | 1131 | return Newtonsoft.Json.JsonConvert.DeserializeObject(data, new Newtonsoft.Json.JsonSerializerSettings()); 1132 | 1133 | } 1134 | 1135 | public event System.ComponentModel.PropertyChangedEventHandler PropertyChanged; 1136 | 1137 | protected virtual void RaisePropertyChanged([System.Runtime.CompilerServices.CallerMemberName] string propertyName = null) 1138 | { 1139 | var handler = PropertyChanged; 1140 | if (handler != null) 1141 | handler(this, new System.ComponentModel.PropertyChangedEventArgs(propertyName)); 1142 | } 1143 | } 1144 | 1145 | 1146 | 1147 | [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.0.2.0 (NJsonSchema v11.0.0.0 (Newtonsoft.Json v13.0.0.0))")] 1148 | public partial class SwaggerException : System.Exception 1149 | { 1150 | public int StatusCode { get; private set; } 1151 | 1152 | public string Response { get; private set; } 1153 | 1154 | public System.Collections.Generic.IReadOnlyDictionary> Headers { get; private set; } 1155 | 1156 | public SwaggerException(string message, int statusCode, string response, System.Collections.Generic.IReadOnlyDictionary> headers, System.Exception innerException) 1157 | : base(message + "\n\nStatus: " + statusCode + "\nResponse: \n" + ((response == null) ? "(null)" : response.Substring(0, response.Length >= 512 ? 512 : response.Length)), innerException) 1158 | { 1159 | StatusCode = statusCode; 1160 | Response = response; 1161 | Headers = headers; 1162 | } 1163 | 1164 | public override string ToString() 1165 | { 1166 | return string.Format("HTTP Response: \n\n{0}\n\n{1}", Response, base.ToString()); 1167 | } 1168 | } 1169 | 1170 | [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.0.2.0 (NJsonSchema v11.0.0.0 (Newtonsoft.Json v13.0.0.0))")] 1171 | public partial class SwaggerException : SwaggerException 1172 | { 1173 | public TResult Result { get; private set; } 1174 | 1175 | public SwaggerException(string message, int statusCode, string response, System.Collections.Generic.IReadOnlyDictionary> headers, TResult result, System.Exception innerException) 1176 | : base(message, statusCode, response, headers, innerException) 1177 | { 1178 | Result = result; 1179 | } 1180 | } 1181 | 1182 | } 1183 | 1184 | #pragma warning restore 108 1185 | #pragma warning restore 114 1186 | #pragma warning restore 472 1187 | #pragma warning restore 612 1188 | #pragma warning restore 1573 1189 | #pragma warning restore 1591 1190 | #pragma warning restore 8073 1191 | #pragma warning restore 3016 1192 | #pragma warning restore 8603 1193 | #pragma warning restore 8604 1194 | #pragma warning restore 8625 -------------------------------------------------------------------------------- /tests/Api.IntegrationTests/Api.IntegrationTests.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net8.0 5 | enable 6 | 7 | false 8 | 9 | MinimalApiArchitecture.Api.IntegrationTests 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | all 21 | runtime; build; native; contentfiles; analyzers; buildtransitive 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /tests/Api.IntegrationTests/ApiWebApplication.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc.Testing; 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using Microsoft.Extensions.Hosting; 5 | using MinimalApiArchitecture.Application.Infrastructure.Persistence; 6 | 7 | namespace MinimalApiArchitecture.Api.IntegrationTests; 8 | 9 | public class ApiWebApplication : WebApplicationFactory 10 | { 11 | public const string TestConnectionString = "Server=(localdb)\\mssqllocaldb;Database=MinimalApiArchitecture_TestDb;Trusted_Connection=True;MultipleActiveResultSets=false"; 12 | 13 | protected override IHost CreateHost(IHostBuilder builder) 14 | { 15 | builder.ConfigureServices(services => 16 | { 17 | services.AddScoped(sp => 18 | { 19 | // Usamos una LocalDB para pruebas de integración 20 | return new DbContextOptionsBuilder() 21 | .UseSqlServer(TestConnectionString) 22 | .UseApplicationServiceProvider(sp) 23 | .Options; 24 | }); 25 | }); 26 | 27 | return base.CreateHost(builder); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/Api.IntegrationTests/Features/ProductsModuleTests.cs: -------------------------------------------------------------------------------- 1 | using FluentAssertions; 2 | using MinimalApiArchitecture.Application.Domain.Entities; 3 | using MinimalApiArchitecture.Application.Features.Products.Commands; 4 | using MinimalApiArchitecture.Application.Features.Products.Queries; 5 | using NUnit.Framework; 6 | using System.Collections.Generic; 7 | using System.Net; 8 | using System.Net.Http.Json; 9 | using System.Threading.Tasks; 10 | 11 | namespace MinimalApiArchitecture.Api.IntegrationTests.Features; 12 | 13 | 14 | public class ProductsModuleTests : TestBase 15 | { 16 | [Test] 17 | public async Task GetProducts() 18 | { 19 | // Arrenge 20 | var testCategory = await AddAsync(new Category(0, "Category Test")); 21 | await AddAsync(new Product(0, "Test 01", "Desc 01", 1, testCategory.CategoryId)); 22 | await AddAsync(new Product(0, "Test 02", "Desc 02", 2, testCategory.CategoryId)); 23 | 24 | var client = Application.CreateClient(); 25 | 26 | // Act 27 | var products = await client.GetFromJsonAsync>("/api/products"); 28 | 29 | // Assert 30 | products.Should().NotBeNullOrEmpty(); 31 | products.Count.Should().Be(2); 32 | } 33 | 34 | [Test] 35 | public async Task CreateProduct() 36 | { 37 | // Arrenge 38 | var testCategory = await AddAsync(new Category(0, "Category Test")); 39 | 40 | var client = Application.CreateClient(); 41 | 42 | // Act 43 | var response = await client.PostAsJsonAsync("api/products", new CreateProduct.CreateProductCommand 44 | { 45 | Description = $"Test product description", 46 | Name = "Test name", 47 | Price = 123456, 48 | CategoryId = testCategory.CategoryId, 49 | }); 50 | 51 | // Assert 52 | response.EnsureSuccessStatusCode(); 53 | } 54 | 55 | [Test] 56 | public async Task UpdateProduct() 57 | { 58 | // Arrenge 59 | var testCategory = await AddAsync(new Category(0, "Category Test")); 60 | var product1 = await AddAsync(new Product(0, "Test 01", "Desc 01", 1, testCategory.CategoryId)); 61 | await AddAsync(new Product(0, "Test 02", "Desc 02", 2, testCategory.CategoryId)); 62 | 63 | var client = Application.CreateClient(); 64 | 65 | // Act 66 | var response = await client.PutAsJsonAsync("api/products", new UpdateProduct.UpdateProductCommand 67 | { 68 | Description = "Updated Desc for ID 1", 69 | Name = "Updated name for ID 1", 70 | Price = 999, 71 | ProductId = product1.ProductId, 72 | CategoryId = product1.CategoryId 73 | }); 74 | 75 | // Assert 76 | response.EnsureSuccessStatusCode(); 77 | 78 | var updated = await FindAsync(product1.ProductId); 79 | 80 | updated.Name.Should().Be("Updated name for ID 1"); 81 | updated.Description.Should().Be("Updated Desc for ID 1"); 82 | updated.Price.Should().Be(999); 83 | } 84 | 85 | [Test] 86 | public async Task DeleteProduct() 87 | { 88 | // Arrenge 89 | var testCategory = await AddAsync(new Category(0, "Category Test")); 90 | var product1 = await AddAsync(new Product(0, "Test 01", "Desc 01", 1, testCategory.CategoryId)); 91 | 92 | var client = Application.CreateClient(); 93 | 94 | // Act 95 | var response = await client.DeleteAsync($"api/products/{product1.ProductId}"); 96 | 97 | 98 | // Assert 99 | response.EnsureSuccessStatusCode(); 100 | 101 | var deleted = await FindAsync(product1.ProductId); 102 | 103 | deleted.Should().BeNull(); 104 | } 105 | 106 | [Test] 107 | public async Task DeleteProduct_Should_Fail() 108 | { 109 | // Arrenge 110 | var client = Application.CreateClient(); 111 | 112 | // Act 113 | var response = await client.DeleteAsync($"api/products/0"); 114 | 115 | // Assert 116 | response.StatusCode.Should().Be(HttpStatusCode.NotFound); 117 | } 118 | } -------------------------------------------------------------------------------- /tests/Api.IntegrationTests/TestBase.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using MinimalApiArchitecture.Application.Infrastructure.Persistence; 4 | using NUnit.Framework; 5 | using Respawn; 6 | using Respawn.Graph; 7 | using System.Threading.Tasks; 8 | 9 | namespace MinimalApiArchitecture.Api.IntegrationTests; 10 | 11 | public class TestBase 12 | { 13 | protected ApiWebApplication Application; 14 | 15 | [OneTimeSetUp] 16 | public void RunBeforeAnyTests() 17 | { 18 | Application = new ApiWebApplication(); 19 | 20 | using var scope = Application.Services.CreateScope(); 21 | 22 | EnsureDatabase(scope); 23 | } 24 | 25 | [OneTimeTearDown] 26 | public void RunAfterAnyTests() 27 | { 28 | Application.Dispose(); 29 | } 30 | 31 | [SetUp] 32 | public async Task Setup() 33 | { 34 | await ResetState(); 35 | } 36 | 37 | [TearDown] 38 | public void Down() 39 | { 40 | 41 | } 42 | 43 | protected async Task AddAsync(TEntity entity) where TEntity : class 44 | { 45 | using var scope = Application.Services.CreateScope(); 46 | 47 | var context = scope.ServiceProvider.GetRequiredService(); 48 | 49 | context.Add(entity); 50 | 51 | await context.SaveChangesAsync(); 52 | 53 | return entity; 54 | } 55 | 56 | protected async Task FindAsync(params object[] keyValues) where TEntity : class 57 | { 58 | using var scope = Application.Services.CreateScope(); 59 | 60 | var context = scope.ServiceProvider.GetRequiredService(); 61 | 62 | return await context.FindAsync(keyValues); 63 | } 64 | 65 | private static void EnsureDatabase(IServiceScope scope) 66 | { 67 | var context = scope.ServiceProvider.GetRequiredService(); 68 | 69 | context.Database.Migrate(); 70 | } 71 | 72 | private static async Task ResetState() 73 | { 74 | var checkpoint = await Respawner.CreateAsync(ApiWebApplication.TestConnectionString, new RespawnerOptions 75 | { 76 | TablesToIgnore = new Table[] 77 | { 78 | "__EFMigrationsHistory" 79 | } 80 | }); 81 | 82 | 83 | await checkpoint.ResetAsync(ApiWebApplication.TestConnectionString); 84 | } 85 | 86 | } 87 | -------------------------------------------------------------------------------- /tests/Application.Unit.Tests/Application.Unit.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | 7 | false 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | all 17 | runtime; build; native; contentfiles; analyzers; buildtransitive 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /tests/Application.Unit.Tests/DbContextInMemoryFactory.cs: -------------------------------------------------------------------------------- 1 | using MediatR; 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.Extensions.Logging; 4 | using MinimalApiArchitecture.Application.Infrastructure.Persistence; 5 | using Moq; 6 | 7 | namespace Application.Unit.Tests; 8 | 9 | public class DbContextInMemoryFactory 10 | { 11 | public static ApiDbContext Create() 12 | { 13 | var options = new DbContextOptionsBuilder() 14 | .UseInMemoryDatabase(nameof(ApiDbContext)) 15 | .Options; 16 | 17 | 18 | return new ApiDbContext(options, Mock.Of(), Mock.Of>()); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/Application.Unit.Tests/Domain/Entities/ProductTests.cs: -------------------------------------------------------------------------------- 1 | using FluentAssertions; 2 | using MinimalApiArchitecture.Application.Domain.Entities; 3 | using MinimalApiArchitecture.Application.Features.Products.Commands; 4 | using NUnit.Framework; 5 | 6 | namespace Application.Unit.Tests.Domain.Entities 7 | { 8 | public class ProductTests 9 | { 10 | [TestCase(1, 0)] 11 | [TestCase(999, 1)] 12 | public void ProductPriceChanged(double price, int eventsCount) 13 | { 14 | // Arrenge 15 | var product = new Product(1, "Name 1", "Description 1", 1, 1); 16 | var command = new UpdateProduct.UpdateProductCommand 17 | { 18 | CategoryId = 1, 19 | Description = "New description", 20 | Name = "New name", 21 | Price = price 22 | }; 23 | 24 | // Act 25 | product.UpdateInfo(command); 26 | 27 | // Assert 28 | product.Price.Should().Be(price); 29 | product.DomainEvents.Count.Should().Be(eventsCount); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tests/Application.Unit.Tests/Features/Products/EventHandlers/PriceChangedEventHandlerTests.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Logging; 2 | using MinimalApiArchitecture.Application.Domain.Entities; 3 | using MinimalApiArchitecture.Application.Domain.Events; 4 | using MinimalApiArchitecture.Application.Features.Products.EventHandlers; 5 | using Moq; 6 | using NUnit.Framework; 7 | using System.Threading; 8 | 9 | namespace Application.Unit.Tests.Features.Products.EventHandlers 10 | { 11 | public class PriceChangedEventHandlerTests 12 | { 13 | [Test] 14 | public void PriceChangedEvent_LoggerCalled() 15 | { 16 | // Arrange 17 | var product = new Product(0, "Test", "Desc", 10, 1); 18 | var domainEvent = new ProductUpdatePriceEvent(product); 19 | var handler = new PriceChangedEventHandler(Mock.Of>()); 20 | 21 | // Act 22 | handler.Handle(domainEvent, CancellationToken.None); 23 | 24 | // Assert 25 | // TODO: Do something first in the event 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/Application.Unit.Tests/TestBase.cs: -------------------------------------------------------------------------------- 1 | using MinimalApiArchitecture.Application.Infrastructure.Persistence; 2 | using NUnit.Framework; 3 | using System.Threading.Tasks; 4 | 5 | namespace Application.Unit.Tests; 6 | 7 | public class TestBase 8 | { 9 | public ApiDbContext Context { get; private set; } 10 | 11 | [SetUp] 12 | protected void Setup() 13 | { 14 | Context = DbContextInMemoryFactory.Create(); 15 | Context.Database.EnsureCreated(); 16 | } 17 | 18 | protected async Task AddAsync(T entity) where T : class 19 | { 20 | await Context.AddAsync(entity); 21 | await Context.SaveChangesAsync(); 22 | 23 | return entity; 24 | } 25 | 26 | [TearDown] 27 | protected void TearDown() 28 | { 29 | Context.Database.EnsureDeleted(); 30 | Context.Dispose(); 31 | } 32 | } 33 | --------------------------------------------------------------------------------