├── .config └── dotnet-tools.json ├── .gitattributes ├── .gitignore ├── .idea └── .idea.GroceryListApi │ └── .idea │ ├── .gitignore │ ├── .name │ ├── encodings.xml │ ├── indexLayout.xml │ ├── sqldialects.xml │ └── vcs.xml ├── GroceryListApi.Tests ├── Endpoints │ ├── GroceryListItemTests │ │ ├── CreateGroceryListItemEndpointsTests.Returns201.verified.txt │ │ ├── CreateGroceryListItemEndpointsTests.cs │ │ ├── DeleteGroceryListItemEndpointsTests.cs │ │ ├── GetAllGroceryListItemEndpointsTests.Returns200.verified.txt │ │ ├── GetAllGroceryListItemEndpointsTests.cs │ │ ├── GetGroceryListItemEndpointsTests.Returns200.verified.txt │ │ ├── GetGroceryListItemEndpointsTests.cs │ │ ├── GroceryListItemEndpointsTestsBase.cs │ │ ├── UpdateGroceryListItemEndpointsTests.Returns202.verified.txt │ │ └── UpdateGroceryListItemEndpointsTests.cs │ ├── GroceryStoreEndpointsTests │ │ ├── CreateGroceryStoreEndpointsTests.Returns201.verified.txt │ │ ├── CreateGroceryStoreEndpointsTests.cs │ │ ├── DeleteGroceryStoreEndpointsTests.cs │ │ ├── GetAllGroceryStoreEndpointsTests.Returns200.verified.txt │ │ ├── GetAllGroceryStoreEndpointsTests.cs │ │ ├── GetGroceryStoreEndpointsTests.Returns200.verified.txt │ │ ├── GetGroceryStoreEndpointsTests.cs │ │ ├── GroceryStoreEndpointsTestsBase.cs │ │ ├── UpdateGroceryStoreEndpointsTests.Returns202.verified.txt │ │ └── UpdateGroceryStoreEndpointsTests.cs │ └── TokenEndpointsTests.cs ├── GroceryListApi.Tests.csproj ├── Infrastructure │ ├── AlphabeticalTestCaseOrderer.cs │ └── GroceryListApiApplicationFactory.cs ├── ModuleInitializer.cs ├── Properties │ └── launchSettings.json └── xunit.runner.json ├── GroceryListApi.sln └── GroceryListApi ├── Authentication └── AddAuthorizationHeaderOperationHeader.cs ├── ClaimsPrincipalExtensions.cs ├── Database ├── GroceryListDb.cs ├── GroceryListItem.cs ├── GroceryStore.cs └── User.cs ├── Endpoints ├── AuthenticationEndpoints.cs ├── GroceryListItemEndpoints.cs ├── GroceryStoreEndpoints.cs ├── Schemas │ ├── ApiItem.cs │ ├── ApiStore.cs │ ├── ApiToken.cs │ └── ApiUser.cs └── SwaggerEndpoints.cs ├── GroceryClaimTypes.cs ├── GroceryListApi.csproj ├── GroceryListApi.csproj.DotSettings ├── HttpResults └── Unauthorized.cs ├── Migrations ├── 20211124165140_Initial.Designer.cs ├── 20211124165140_Initial.cs ├── 20211230091359_IntroduceGroceryStore.Designer.cs ├── 20211230091359_IntroduceGroceryStore.cs ├── 20211230093751_ImproveDatabaseModel.Designer.cs ├── 20211230093751_ImproveDatabaseModel.cs ├── 20221027065018_SeedData.Designer.cs ├── 20221027065018_SeedData.cs └── GroceryListDbModelSnapshot.cs ├── Program.cs ├── Properties └── launchSettings.json ├── Startup ├── AuthenticationExtensions.cs └── SwaggerExtensions.cs ├── appsettings.Development.json └── appsettings.json /.config/dotnet-tools.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "isRoot": true, 4 | "tools": { 5 | "dotnet-ef": { 6 | "version": "8.0.0", 7 | "commands": [ 8 | "dotnet-ef" 9 | ] 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | 4 | # Custom for Visual Studio 5 | *.cs diff=csharp 6 | 7 | # Standard to msysgit 8 | *.doc diff=astextplain 9 | *.DOC diff=astextplain 10 | *.docx diff=astextplain 11 | *.DOCX diff=astextplain 12 | *.dot diff=astextplain 13 | *.DOT diff=astextplain 14 | *.pdf diff=astextplain 15 | *.PDF diff=astextplain 16 | *.rtf diff=astextplain 17 | *.RTF diff=astextplain 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ################# 2 | ## Visual Studio 3 | ################# 4 | 5 | ## Ignore Visual Studio temporary files, build results, and 6 | ## files generated by popular Visual Studio add-ons. 7 | 8 | # User-specific files 9 | *.suo 10 | *.user 11 | *.sln.docstates 12 | 13 | # Build results 14 | 15 | [Dd]ebug/ 16 | [Rr]elease/ 17 | x64/ 18 | [Bb]in/ 19 | [Oo]bj/ 20 | 21 | # MSTest test Results 22 | [Tt]est[Rr]esult*/ 23 | [Bb]uild[Ll]og.* 24 | 25 | *_i.c 26 | *_p.c 27 | *.ilk 28 | *.meta 29 | *.obj 30 | *.pch 31 | *.pdb 32 | *.pgc 33 | *.pgd 34 | *.rsp 35 | *.sbr 36 | *.tlb 37 | *.tli 38 | *.tlh 39 | *.tmp 40 | *.tmp_proj 41 | *.log 42 | *.vspscc 43 | *.vssscc 44 | .builds 45 | *.pidb 46 | *.log 47 | *.scc 48 | 49 | # Visual C++ cache files 50 | ipch/ 51 | *.aps 52 | *.ncb 53 | *.opensdf 54 | *.sdf 55 | *.cachefile 56 | 57 | # Visual Studio profiler 58 | *.psess 59 | *.vsp 60 | *.vspx 61 | 62 | # Guidance Automation Toolkit 63 | *.gpState 64 | 65 | # ReSharper is a .NET coding add-in 66 | _ReSharper*/ 67 | *.[Rr]e[Ss]harper 68 | 69 | # TeamCity is a build add-in 70 | _TeamCity* 71 | 72 | # DotCover is a Code Coverage Tool 73 | *.dotCover 74 | 75 | # NCrunch 76 | *.ncrunch* 77 | .*crunch*.local.xml 78 | 79 | # Installshield output folder 80 | [Ee]xpress/ 81 | 82 | # DocProject is a documentation generator add-in 83 | DocProject/buildhelp/ 84 | DocProject/Help/*.HxT 85 | DocProject/Help/*.HxC 86 | DocProject/Help/*.hhc 87 | DocProject/Help/*.hhk 88 | DocProject/Help/*.hhp 89 | DocProject/Help/Html2 90 | DocProject/Help/html 91 | 92 | # Click-Once directory 93 | publish/ 94 | 95 | # Publish Web Output 96 | *.Publish.xml 97 | *.pubxml 98 | *.publishproj 99 | 100 | # NuGet Packages Directory 101 | ## TODO: If you have NuGet Package Restore enabled, uncomment the next line 102 | #packages/ 103 | 104 | # Windows Azure Build Output 105 | csx 106 | *.build.csdef 107 | 108 | # Windows Store app package directory 109 | AppPackages/ 110 | 111 | # Others 112 | sql/ 113 | *.Cache 114 | ClientBin/ 115 | [Ss]tyle[Cc]op.* 116 | ~$* 117 | *~ 118 | *.dbmdl 119 | *.[Pp]ublish.xml 120 | *.pfx 121 | *.publishsettings 122 | 123 | # RIA/Silverlight projects 124 | Generated_Code/ 125 | 126 | # Backup & report files from converting an old project file to a newer 127 | # Visual Studio version. Backup files are not needed, because we have git ;-) 128 | _UpgradeReport_Files/ 129 | Backup*/ 130 | UpgradeLog*.XML 131 | UpgradeLog*.htm 132 | 133 | # SQL Server files 134 | App_Data/*.mdf 135 | App_Data/*.ldf 136 | 137 | ############# 138 | ## Windows detritus 139 | ############# 140 | 141 | # Windows image file caches 142 | Thumbs.db 143 | ehthumbs.db 144 | 145 | # Folder config file 146 | Desktop.ini 147 | 148 | # Recycle Bin used on file shares 149 | $RECYCLE.BIN/ 150 | 151 | # Mac crap 152 | .DS_Store 153 | 154 | 155 | ############# 156 | ## Rider 157 | ############# 158 | 159 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 160 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 161 | 162 | # User-specific stuff: 163 | .idea/**/workspace.xml 164 | .idea/**/tasks.xml 165 | .idea/dictionaries 166 | 167 | # Sensitive or high-churn files: 168 | .idea/**/dataSources/ 169 | .idea/**/dataSources.ids 170 | .idea/**/dataSources.xml 171 | .idea/**/dataSources.local.xml 172 | .idea/**/sqlDataSources.xml 173 | .idea/**/dynamic.xml 174 | .idea/**/uiDesigner.xml 175 | .idea/**/riderModule.iml 176 | 177 | # Gradle: 178 | .idea/**/gradle.xml 179 | .idea/**/libraries 180 | 181 | # Mongo Explorer plugin: 182 | .idea/**/mongoSettings.xml 183 | 184 | ## File-based project format: 185 | *.iws 186 | 187 | ## Plugin-specific files: 188 | 189 | # IntelliJ 190 | /out/ 191 | 192 | # mpeltonen/sbt-idea plugin 193 | .idea_modules/ 194 | 195 | # JIRA plugin 196 | atlassian-ide-plugin.xml 197 | 198 | # Crashlytics plugin (for Android Studio and IntelliJ) 199 | com_crashlytics_export_strings.xml 200 | crashlytics.properties 201 | crashlytics-build.properties 202 | fabric.properties 203 | 204 | # Our own folders 205 | artifacts/ 206 | /.vs 207 | 208 | # Database 209 | grocerylists.db* 210 | riderMarkupCache.xml -------------------------------------------------------------------------------- /.idea/.idea.GroceryListApi/.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Rider ignored files 5 | /modules.xml 6 | /projectSettingsUpdater.xml 7 | /contentModel.xml 8 | /.idea.GroceryListApi.iml 9 | # Editor-based HTTP Client requests 10 | /httpRequests/ 11 | # Datasource local storage ignored files 12 | /dataSources/ 13 | /dataSources.local.xml 14 | -------------------------------------------------------------------------------- /.idea/.idea.GroceryListApi/.idea/.name: -------------------------------------------------------------------------------- 1 | GroceryListApi -------------------------------------------------------------------------------- /.idea/.idea.GroceryListApi/.idea/encodings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.idea/.idea.GroceryListApi/.idea/indexLayout.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/.idea.GroceryListApi/.idea/sqldialects.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /.idea/.idea.GroceryListApi/.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /GroceryListApi.Tests/Endpoints/GroceryListItemTests/CreateGroceryListItemEndpointsTests.Returns201.verified.txt: -------------------------------------------------------------------------------- 1 | { 2 | Version: 1.1, 3 | Status: 201 Created, 4 | Headers: { 5 | Location: http://localhost/stores/1/items/4 6 | }, 7 | Content: { 8 | Headers: { 9 | Content-Type: application/json; charset=utf-8 10 | }, 11 | Value: { 12 | id: 4, 13 | storeId: 1, 14 | title: Test item, 15 | isComplete: false 16 | } 17 | }, 18 | Request: { 19 | Method: POST, 20 | Uri: http://localhost/stores/1/items, 21 | Headers: {}, 22 | Content: { 23 | Headers: { 24 | Content-Length: 65, 25 | Content-Type: application/json; charset=utf-8 26 | }, 27 | Value: 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /GroceryListApi.Tests/Endpoints/GroceryListItemTests/CreateGroceryListItemEndpointsTests.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Net.Http.Json; 3 | using System.Threading.Tasks; 4 | using GroceryListApi.Endpoints.Schemas; 5 | using GroceryListApi.Tests.Infrastructure; 6 | using VerifyXunit; 7 | using Xunit; 8 | 9 | namespace GroceryListApi.Tests.Endpoints.GroceryListItemTests; 10 | 11 | [UsesVerify] 12 | [TestCaseOrderer("GroceryListApi.Tests.Infrastructure.AlphabeticalTestCaseOrderer", "GroceryListApi.Tests")] 13 | public class CreateGroceryListItemEndpointsTests : GroceryListItemEndpointsTestsBase 14 | { 15 | public CreateGroceryListItemEndpointsTests(GroceryListApiApplicationFactory factory) : base(factory) { } 16 | 17 | [Fact] 18 | public async Task Returns201() 19 | { 20 | // Arrange 21 | await EnsureAuthorizedAsync(); 22 | 23 | // Act 24 | var result = await Application.PostAsJsonAsync("/stores/1/items", 25 | new ApiItem(null, null, "Test item", false)); 26 | 27 | // Assert 28 | await Verifier.Verify(result); 29 | } 30 | 31 | [Theory] 32 | [InlineData("")] 33 | [InlineData(null)] 34 | public async Task Returns400ForInvalidModel(string? title) 35 | { 36 | // Arrange 37 | await EnsureAuthorizedAsync(); 38 | 39 | // Act 40 | var result = await Application.PostAsJsonAsync("/stores/1/items", 41 | new ApiItem(null, null, title!, true)); 42 | 43 | // Assert 44 | Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode); 45 | } 46 | } -------------------------------------------------------------------------------- /GroceryListApi.Tests/Endpoints/GroceryListItemTests/DeleteGroceryListItemEndpointsTests.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Net.Http.Json; 3 | using System.Threading.Tasks; 4 | using GroceryListApi.Endpoints.Schemas; 5 | using GroceryListApi.Tests.Infrastructure; 6 | using Xunit; 7 | 8 | namespace GroceryListApi.Tests.Endpoints.GroceryListItemTests; 9 | 10 | [TestCaseOrderer("GroceryListApi.Tests.Infrastructure.AlphabeticalTestCaseOrderer", "GroceryListApi.Tests")] 11 | public class DeleteGroceryListItemEndpointsTests : GroceryListItemEndpointsTestsBase 12 | { 13 | public DeleteGroceryListItemEndpointsTests(GroceryListApiApplicationFactory factory) : base(factory) { } 14 | 15 | [Fact] 16 | public async Task Returns200() 17 | { 18 | // Arrange 19 | await EnsureAuthorizedAsync(); 20 | var createdItemResult = await Application.PostAsJsonAsync("/stores/1/items", 21 | new ApiItem(null, null, "Test item", true)); 22 | var createdItem = await createdItemResult.Content.ReadFromJsonAsync(); 23 | 24 | // Act 25 | var result = await Application.DeleteAsync($"/stores/1/items/{createdItem!.Id}"); 26 | 27 | // Assert 28 | Assert.Equal(HttpStatusCode.OK, result.StatusCode); 29 | } 30 | 31 | [Fact] 32 | public async Task Returns404() 33 | { 34 | // Arrange 35 | await EnsureAuthorizedAsync(); 36 | 37 | // Act 38 | var result = await Application.DeleteAsync("/stores/1/items/123"); 39 | 40 | // Assert 41 | Assert.Equal(HttpStatusCode.NotFound, result.StatusCode); 42 | } 43 | } -------------------------------------------------------------------------------- /GroceryListApi.Tests/Endpoints/GroceryListItemTests/GetAllGroceryListItemEndpointsTests.Returns200.verified.txt: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | id: 1, 4 | storeId: 1, 5 | title: Test 1-1, 6 | isComplete: true 7 | }, 8 | { 9 | id: 2, 10 | storeId: 1, 11 | title: Test 1-2, 12 | isComplete: false 13 | } 14 | ] 15 | -------------------------------------------------------------------------------- /GroceryListApi.Tests/Endpoints/GroceryListItemTests/GetAllGroceryListItemEndpointsTests.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Threading.Tasks; 3 | using GroceryListApi.Tests.Infrastructure; 4 | using VerifyXunit; 5 | using Xunit; 6 | 7 | namespace GroceryListApi.Tests.Endpoints.GroceryListItemTests; 8 | 9 | [UsesVerify] 10 | [TestCaseOrderer("GroceryListApi.Tests.Infrastructure.AlphabeticalTestCaseOrderer", "GroceryListApi.Tests")] 11 | public class GetAllGroceryListItemEndpointsTests : GroceryListItemEndpointsTestsBase 12 | { 13 | public GetAllGroceryListItemEndpointsTests(GroceryListApiApplicationFactory factory) : base(factory) { } 14 | 15 | [Fact] 16 | public async Task Returns200() 17 | { 18 | // Arrange 19 | await EnsureAuthorizedAsync(); 20 | 21 | // Act 22 | var result = await Application.GetAsync("/stores/1/items"); 23 | 24 | // Assert 25 | Assert.Equal(HttpStatusCode.OK, result.StatusCode); 26 | await Verifier.VerifyJson(await result.Content.ReadAsStringAsync()); 27 | } 28 | } -------------------------------------------------------------------------------- /GroceryListApi.Tests/Endpoints/GroceryListItemTests/GetGroceryListItemEndpointsTests.Returns200.verified.txt: -------------------------------------------------------------------------------- 1 | { 2 | id: 1, 3 | storeId: 1, 4 | title: Test 1-1, 5 | isComplete: true 6 | } 7 | -------------------------------------------------------------------------------- /GroceryListApi.Tests/Endpoints/GroceryListItemTests/GetGroceryListItemEndpointsTests.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Threading.Tasks; 3 | using GroceryListApi.Tests.Infrastructure; 4 | using VerifyXunit; 5 | using Xunit; 6 | 7 | namespace GroceryListApi.Tests.Endpoints.GroceryListItemTests; 8 | 9 | [UsesVerify] 10 | [TestCaseOrderer("GroceryListApi.Tests.Infrastructure.AlphabeticalTestCaseOrderer", "GroceryListApi.Tests")] 11 | public class GetGroceryListItemEndpointsTests : GroceryListItemEndpointsTestsBase 12 | { 13 | public GetGroceryListItemEndpointsTests(GroceryListApiApplicationFactory factory) : base(factory) { } 14 | 15 | [Fact] 16 | public async Task Returns200() 17 | { 18 | // Arrange 19 | await EnsureAuthorizedAsync(); 20 | 21 | // Act 22 | var result = await Application.GetAsync("/stores/1/items/1"); 23 | 24 | // Assert 25 | Assert.Equal(HttpStatusCode.OK, result.StatusCode); 26 | await Verifier.VerifyJson(await result.Content.ReadAsStringAsync()); 27 | } 28 | 29 | [Fact] 30 | public async Task Returns404() 31 | { 32 | // Arrange 33 | await EnsureAuthorizedAsync(); 34 | 35 | // Act 36 | var result = await Application.GetAsync("/stores/123/items/123"); 37 | 38 | // Assert 39 | Assert.Equal(HttpStatusCode.NotFound, result.StatusCode); 40 | } 41 | } -------------------------------------------------------------------------------- /GroceryListApi.Tests/Endpoints/GroceryListItemTests/GroceryListItemEndpointsTestsBase.cs: -------------------------------------------------------------------------------- 1 | using System.Net.Http; 2 | using System.Net.Http.Headers; 3 | using System.Net.Http.Json; 4 | using System.Threading.Tasks; 5 | using GroceryListApi.Endpoints.Schemas; 6 | using GroceryListApi.Tests.Infrastructure; 7 | using Xunit; 8 | 9 | namespace GroceryListApi.Tests.Endpoints.GroceryListItemTests; 10 | 11 | [TestCaseOrderer("GroceryListApi.Tests.Infrastructure.AlphabeticalTestCaseOrderer", "GroceryListApi.Tests")] 12 | public abstract class GroceryListItemEndpointsTestsBase : IClassFixture 13 | { 14 | protected HttpClient Application { get; } 15 | 16 | public GroceryListItemEndpointsTestsBase(GroceryListApiApplicationFactory factory) 17 | { 18 | Application = factory.CreateClient(); 19 | } 20 | 21 | public async Task EnsureAuthorizedAsync() 22 | { 23 | var tokenResult = await Application.PostAsJsonAsync("/token", 24 | new ApiUser("test", "1234")); 25 | 26 | var apiToken = await tokenResult.Content.ReadFromJsonAsync(); 27 | Application.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiToken!.Token); 28 | } 29 | } -------------------------------------------------------------------------------- /GroceryListApi.Tests/Endpoints/GroceryListItemTests/UpdateGroceryListItemEndpointsTests.Returns202.verified.txt: -------------------------------------------------------------------------------- 1 | { 2 | id: 4, 3 | storeId: 1, 4 | title: Updated title, 5 | isComplete: true 6 | } 7 | -------------------------------------------------------------------------------- /GroceryListApi.Tests/Endpoints/GroceryListItemTests/UpdateGroceryListItemEndpointsTests.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Net.Http.Json; 3 | using System.Threading.Tasks; 4 | using GroceryListApi.Endpoints.Schemas; 5 | using GroceryListApi.Tests.Infrastructure; 6 | using VerifyXunit; 7 | using Xunit; 8 | 9 | namespace GroceryListApi.Tests.Endpoints.GroceryListItemTests; 10 | 11 | [UsesVerify] 12 | [TestCaseOrderer("GroceryListApi.Tests.Infrastructure.AlphabeticalTestCaseOrderer", "GroceryListApi.Tests")] 13 | public class UpdateGroceryListItemEndpointsTests : GroceryListItemEndpointsTestsBase 14 | { 15 | public UpdateGroceryListItemEndpointsTests(GroceryListApiApplicationFactory factory) : base(factory) { } 16 | 17 | [Fact] 18 | public async Task Returns202() 19 | { 20 | // Arrange 21 | await EnsureAuthorizedAsync(); 22 | var createdItemResult = await Application.PostAsJsonAsync("/stores/1/items", 23 | new ApiItem(null, null, "Test item", false)); 24 | var createdItem = await createdItemResult.Content.ReadFromJsonAsync(); 25 | 26 | // Act 27 | var result = await Application.PutAsJsonAsync($"/stores/{createdItem!.StoreId}/items/{createdItem.Id}", 28 | new ApiItem(null, null, "Updated title", true)); 29 | 30 | // Assert 31 | Assert.Equal(HttpStatusCode.Accepted, result.StatusCode); 32 | await Verifier.VerifyJson(await result.Content.ReadAsStringAsync()); 33 | } 34 | 35 | [Theory] 36 | [InlineData("")] 37 | [InlineData(null)] 38 | public async Task Returns400ForInvalidModel(string? title) 39 | { 40 | // Arrange 41 | await EnsureAuthorizedAsync(); 42 | 43 | // Act 44 | var result = await Application.PutAsJsonAsync($"/stores/1/items/1", 45 | new ApiItem(null, null, title!, true)); 46 | 47 | // Assert 48 | Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode); 49 | } 50 | 51 | [Fact] 52 | public async Task Returns404() 53 | { 54 | // Arrange 55 | await EnsureAuthorizedAsync(); 56 | 57 | // Act 58 | var result = await Application.PutAsJsonAsync("/stores/1/items/123", 59 | new ApiItem(null, null, "Test", false)); 60 | 61 | // Assert 62 | Assert.Equal(HttpStatusCode.NotFound, result.StatusCode); 63 | } 64 | } -------------------------------------------------------------------------------- /GroceryListApi.Tests/Endpoints/GroceryStoreEndpointsTests/CreateGroceryStoreEndpointsTests.Returns201.verified.txt: -------------------------------------------------------------------------------- 1 | { 2 | id: 3, 3 | name: Test store, 4 | description: This is a test store 5 | } 6 | -------------------------------------------------------------------------------- /GroceryListApi.Tests/Endpoints/GroceryStoreEndpointsTests/CreateGroceryStoreEndpointsTests.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Net.Http.Json; 3 | using System.Threading.Tasks; 4 | using GroceryListApi.Endpoints.Schemas; 5 | using GroceryListApi.Tests.Infrastructure; 6 | using VerifyXunit; 7 | using Xunit; 8 | 9 | namespace GroceryListApi.Tests.Endpoints.GroceryStoreEndpointsTests; 10 | 11 | [UsesVerify] 12 | [TestCaseOrderer("GroceryListApi.Tests.Infrastructure.AlphabeticalTestCaseOrderer", "GroceryListApi.Tests")] 13 | public class CreateGroceryStoreEndpointsTests : GroceryStoreEndpointsTestsBase 14 | { 15 | public CreateGroceryStoreEndpointsTests(GroceryListApiApplicationFactory factory) : base(factory) { } 16 | 17 | [Fact] 18 | public async Task Returns201() 19 | { 20 | // Arrange 21 | await EnsureAuthorizedAsync(); 22 | 23 | // Act 24 | var result = await Application.PostAsJsonAsync("/stores", 25 | new ApiStore(null, "Test store", "This is a test store")); 26 | 27 | // Assert 28 | Assert.Equal(HttpStatusCode.Created, result.StatusCode); 29 | await Verifier.VerifyJson(await result.Content.ReadAsStringAsync()); 30 | } 31 | 32 | [Theory] 33 | [InlineData("", "")] 34 | [InlineData(null, null)] 35 | public async Task Returns400ForInvalidModel(string? title, string? description) 36 | { 37 | // Arrange 38 | await EnsureAuthorizedAsync(); 39 | 40 | // Act 41 | var result = await Application.PostAsJsonAsync("/stores", 42 | new ApiStore(null, title!, description)); 43 | 44 | // Assert 45 | Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode); 46 | } 47 | } -------------------------------------------------------------------------------- /GroceryListApi.Tests/Endpoints/GroceryStoreEndpointsTests/DeleteGroceryStoreEndpointsTests.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Net.Http.Json; 3 | using System.Threading.Tasks; 4 | using GroceryListApi.Endpoints.Schemas; 5 | using GroceryListApi.Tests.Infrastructure; 6 | using Xunit; 7 | 8 | namespace GroceryListApi.Tests.Endpoints.GroceryStoreEndpointsTests; 9 | 10 | [TestCaseOrderer("GroceryListApi.Tests.Infrastructure.AlphabeticalTestCaseOrderer", "GroceryListApi.Tests")] 11 | public class DeleteGroceryStoreEndpointsTests : GroceryStoreEndpointsTestsBase 12 | { 13 | public DeleteGroceryStoreEndpointsTests(GroceryListApiApplicationFactory factory) : base(factory) { } 14 | 15 | [Fact] 16 | public async Task Returns200() 17 | { 18 | // Arrange 19 | await EnsureAuthorizedAsync(); 20 | var createdStoreResult = await Application.PostAsJsonAsync("/stores", 21 | new ApiStore(null, "Test store", "This is a test store")); 22 | var createdStore = createdStoreResult.Content.ReadFromJsonAsync(); 23 | 24 | // Act 25 | var result = await Application.DeleteAsync($"/stores/{createdStore.Id}"); 26 | 27 | // Assert 28 | Assert.Equal(HttpStatusCode.OK, result.StatusCode); 29 | } 30 | 31 | [Fact] 32 | public async Task Returns404() 33 | { 34 | // Arrange 35 | await EnsureAuthorizedAsync(); 36 | 37 | // Act 38 | var result = await Application.DeleteAsync("/stores/123"); 39 | 40 | // Assert 41 | Assert.Equal(HttpStatusCode.NotFound, result.StatusCode); 42 | } 43 | } -------------------------------------------------------------------------------- /GroceryListApi.Tests/Endpoints/GroceryStoreEndpointsTests/GetAllGroceryStoreEndpointsTests.Returns200.verified.txt: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | id: 1, 4 | name: Test, 5 | description: Test store 1 6 | }, 7 | { 8 | id: 2, 9 | name: Test, 10 | description: Test store 2 11 | } 12 | ] 13 | -------------------------------------------------------------------------------- /GroceryListApi.Tests/Endpoints/GroceryStoreEndpointsTests/GetAllGroceryStoreEndpointsTests.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Threading.Tasks; 3 | using GroceryListApi.Tests.Infrastructure; 4 | using VerifyXunit; 5 | using Xunit; 6 | 7 | namespace GroceryListApi.Tests.Endpoints.GroceryStoreEndpointsTests; 8 | 9 | [UsesVerify] 10 | [TestCaseOrderer("GroceryListApi.Tests.Infrastructure.AlphabeticalTestCaseOrderer", "GroceryListApi.Tests")] 11 | public class GetAllGroceryStoreEndpointsTests : GroceryStoreEndpointsTestsBase 12 | { 13 | public GetAllGroceryStoreEndpointsTests(GroceryListApiApplicationFactory factory) : base(factory) { } 14 | 15 | [Fact] 16 | public async Task Returns200() 17 | { 18 | // Arrange 19 | await EnsureAuthorizedAsync(); 20 | 21 | // Act 22 | var result = await Application.GetAsync("/stores"); 23 | 24 | // Assert 25 | Assert.Equal(HttpStatusCode.OK, result.StatusCode); 26 | await Verifier.VerifyJson(await result.Content.ReadAsStringAsync()); 27 | } 28 | } -------------------------------------------------------------------------------- /GroceryListApi.Tests/Endpoints/GroceryStoreEndpointsTests/GetGroceryStoreEndpointsTests.Returns200.verified.txt: -------------------------------------------------------------------------------- 1 | { 2 | id: 1, 3 | name: Test, 4 | description: Test store 1 5 | } 6 | -------------------------------------------------------------------------------- /GroceryListApi.Tests/Endpoints/GroceryStoreEndpointsTests/GetGroceryStoreEndpointsTests.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Threading.Tasks; 3 | using GroceryListApi.Tests.Infrastructure; 4 | using VerifyXunit; 5 | using Xunit; 6 | 7 | namespace GroceryListApi.Tests.Endpoints.GroceryStoreEndpointsTests; 8 | 9 | [UsesVerify] 10 | [TestCaseOrderer("GroceryListApi.Tests.Infrastructure.AlphabeticalTestCaseOrderer", "GroceryListApi.Tests")] 11 | public class GetGroceryStoreEndpointsTests : GroceryStoreEndpointsTestsBase 12 | { 13 | public GetGroceryStoreEndpointsTests(GroceryListApiApplicationFactory factory) : base(factory) { } 14 | 15 | [Fact] 16 | public async Task Returns200() 17 | { 18 | // Arrange 19 | await EnsureAuthorizedAsync(); 20 | 21 | // Act 22 | var result = await Application.GetAsync("/stores/1"); 23 | 24 | // Assert 25 | Assert.Equal(HttpStatusCode.OK, result.StatusCode); 26 | await Verifier.VerifyJson(await result.Content.ReadAsStringAsync()); 27 | } 28 | 29 | [Fact] 30 | public async Task Returns404() 31 | { 32 | // Arrange 33 | await EnsureAuthorizedAsync(); 34 | 35 | // Act 36 | var result = await Application.GetAsync("/stores/123"); 37 | 38 | // Assert 39 | Assert.Equal(HttpStatusCode.NotFound, result.StatusCode); 40 | } 41 | } -------------------------------------------------------------------------------- /GroceryListApi.Tests/Endpoints/GroceryStoreEndpointsTests/GroceryStoreEndpointsTestsBase.cs: -------------------------------------------------------------------------------- 1 | using System.Net.Http; 2 | using System.Net.Http.Headers; 3 | using System.Net.Http.Json; 4 | using System.Threading.Tasks; 5 | using GroceryListApi.Endpoints.Schemas; 6 | using GroceryListApi.Tests.Infrastructure; 7 | using Xunit; 8 | 9 | namespace GroceryListApi.Tests.Endpoints.GroceryStoreEndpointsTests; 10 | 11 | [TestCaseOrderer("GroceryListApi.Tests.Infrastructure.AlphabeticalTestCaseOrderer", "GroceryListApi.Tests")] 12 | public abstract class GroceryStoreEndpointsTestsBase : IClassFixture 13 | { 14 | protected HttpClient Application { get; } 15 | 16 | public GroceryStoreEndpointsTestsBase(GroceryListApiApplicationFactory factory) 17 | { 18 | Application = factory.CreateClient(); 19 | } 20 | 21 | public async Task EnsureAuthorizedAsync() 22 | { 23 | var tokenResult = await Application.PostAsJsonAsync("/token", 24 | new ApiUser("test", "1234")); 25 | 26 | var apiToken = await tokenResult.Content.ReadFromJsonAsync(); 27 | Application.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiToken!.Token); 28 | } 29 | } -------------------------------------------------------------------------------- /GroceryListApi.Tests/Endpoints/GroceryStoreEndpointsTests/UpdateGroceryStoreEndpointsTests.Returns202.verified.txt: -------------------------------------------------------------------------------- 1 | { 2 | id: 1, 3 | name: Updated title, 4 | description: Updated description 5 | } -------------------------------------------------------------------------------- /GroceryListApi.Tests/Endpoints/GroceryStoreEndpointsTests/UpdateGroceryStoreEndpointsTests.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Net.Http.Json; 3 | using System.Threading.Tasks; 4 | using GroceryListApi.Endpoints.Schemas; 5 | using GroceryListApi.Tests.Infrastructure; 6 | using VerifyXunit; 7 | using Xunit; 8 | 9 | namespace GroceryListApi.Tests.Endpoints.GroceryStoreEndpointsTests; 10 | 11 | [UsesVerify] 12 | [TestCaseOrderer("GroceryListApi.Tests.Infrastructure.AlphabeticalTestCaseOrderer", "GroceryListApi.Tests")] 13 | public class UpdateGroceryStoreEndpointsTests : GroceryStoreEndpointsTestsBase 14 | { 15 | public UpdateGroceryStoreEndpointsTests(GroceryListApiApplicationFactory factory) : base(factory) { } 16 | 17 | [Fact] 18 | public async Task Returns202() 19 | { 20 | // Arrange 21 | await EnsureAuthorizedAsync(); 22 | var createdStoreResult = await Application.PostAsJsonAsync("/stores", 23 | new ApiStore(null, "Test store", "This is a test store")); 24 | var createdStore = createdStoreResult.Content.ReadFromJsonAsync(); 25 | 26 | // Act 27 | var result = await Application.PutAsJsonAsync($"/stores/{createdStore.Id}", 28 | new ApiStore(null, "Updated title", "Updated description")); 29 | 30 | // Assert 31 | Assert.Equal(HttpStatusCode.Accepted, result.StatusCode); 32 | await Verifier.VerifyJson(await result.Content.ReadAsStringAsync()); 33 | } 34 | 35 | [Theory] 36 | [InlineData("", "")] 37 | [InlineData(null, null)] 38 | public async Task Returns400ForInvalidModel(string? title, string? description) 39 | { 40 | // Arrange 41 | await EnsureAuthorizedAsync(); 42 | 43 | // Act 44 | var result = await Application.PutAsJsonAsync($"/stores/1", 45 | new ApiStore(null, title!, description)); 46 | 47 | // Assert 48 | Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode); 49 | } 50 | 51 | [Fact] 52 | public async Task Returns404() 53 | { 54 | // Arrange 55 | await EnsureAuthorizedAsync(); 56 | 57 | // Act 58 | var result = await Application.PutAsJsonAsync("/stores/123", 59 | new ApiStore(null, "New test store", "New test description")); 60 | 61 | // Assert 62 | Assert.Equal(HttpStatusCode.NotFound, result.StatusCode); 63 | } 64 | } -------------------------------------------------------------------------------- /GroceryListApi.Tests/Endpoints/TokenEndpointsTests.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Net.Http; 3 | using System.Net.Http.Json; 4 | using System.Threading.Tasks; 5 | using GroceryListApi.Endpoints.Schemas; 6 | using GroceryListApi.Tests.Infrastructure; 7 | using Xunit; 8 | 9 | namespace GroceryListApi.Tests.Endpoints; 10 | 11 | [TestCaseOrderer("GroceryListApi.Tests.Infrastructure.AlphabeticalTestCaseOrderer", "GroceryListApi.Tests")] 12 | public class TokenEndpointsTests : IClassFixture 13 | { 14 | private HttpClient Application { get; } 15 | 16 | public TokenEndpointsTests(GroceryListApiApplicationFactory factory) 17 | { 18 | Application = factory.CreateClient(); 19 | } 20 | 21 | [Fact] 22 | public async Task Returns200AndTokenForValidUser() 23 | { 24 | // Act 25 | var result = await Application.PostAsJsonAsync("/token", 26 | new ApiUser("test", "1234")); 27 | 28 | // Assert 29 | Assert.Equal(HttpStatusCode.OK, result.StatusCode); 30 | 31 | var apiToken = await result.Content.ReadFromJsonAsync(); 32 | Assert.NotNull(apiToken?.Token); 33 | } 34 | 35 | [Theory] 36 | [InlineData("", "")] 37 | [InlineData("foo", "")] 38 | [InlineData(null, null)] 39 | public async Task Returns401ForInvalidModel(string? username, string? password) 40 | { 41 | // Act 42 | var result = await Application.PostAsJsonAsync("/token", 43 | new ApiUser(username!, password!)); 44 | 45 | // Assert 46 | Assert.Equal(HttpStatusCode.Unauthorized, result.StatusCode); 47 | } 48 | } -------------------------------------------------------------------------------- /GroceryListApi.Tests/GroceryListApi.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | 7 | false 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | runtime; build; native; contentfiles; analyzers; buildtransitive 20 | all 21 | 22 | 23 | runtime; build; native; contentfiles; analyzers; buildtransitive 24 | all 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /GroceryListApi.Tests/Infrastructure/AlphabeticalTestCaseOrderer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using JetBrains.Annotations; 5 | using Xunit.Abstractions; 6 | using Xunit.Sdk; 7 | 8 | namespace GroceryListApi.Tests.Infrastructure; 9 | 10 | [PublicAPI] 11 | public class AlphabeticalTestCaseOrderer : ITestCaseOrderer 12 | { 13 | private IMessageSink _diagnosticMessageSink; 14 | 15 | public AlphabeticalTestCaseOrderer(IMessageSink diagnosticMessageSink) 16 | => _diagnosticMessageSink = diagnosticMessageSink; 17 | 18 | public IEnumerable OrderTestCases(IEnumerable testCases) 19 | where TTestCase : ITestCase 20 | { 21 | var result = testCases.ToList(); 22 | result.Sort((x, y) => StringComparer.OrdinalIgnoreCase.Compare(x.TestMethod.Method.Name, y.TestMethod.Method.Name)); 23 | return result; 24 | } 25 | } -------------------------------------------------------------------------------- /GroceryListApi.Tests/Infrastructure/GroceryListApiApplicationFactory.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using GroceryListApi.Database; 4 | using JetBrains.Annotations; 5 | using Microsoft.AspNetCore.Mvc.Testing; 6 | using Microsoft.EntityFrameworkCore; 7 | using Microsoft.Extensions.DependencyInjection; 8 | using Microsoft.Extensions.Hosting; 9 | 10 | namespace GroceryListApi.Tests.Infrastructure; 11 | 12 | [UsedImplicitly] 13 | public class GroceryListApiApplicationFactory : WebApplicationFactory 14 | { 15 | private readonly object _dbLock = new(); 16 | private readonly string _dbName = Guid.NewGuid().ToString(); 17 | 18 | protected override IHost CreateHost(IHostBuilder builder) 19 | { 20 | builder.ConfigureServices(services => 21 | { 22 | // Set in-memory database 23 | var descriptor = services.Single( 24 | d => d.ServiceType == typeof(DbContextOptions)); 25 | services.Remove(descriptor); 26 | 27 | services.AddDbContext(options => 28 | { 29 | options.UseInMemoryDatabase(_dbName); 30 | }); 31 | }); 32 | 33 | var app = base.CreateHost(builder); 34 | 35 | // Seed data 36 | using var scope = app.Services.CreateScope(); 37 | using var db = scope.ServiceProvider.GetService()!; 38 | if (!db.Users.Any()) 39 | { 40 | lock (_dbLock) 41 | { 42 | if (!db.Users.Any()) 43 | { 44 | db.Users.Add(new User { Id = 1, Username = "test", Name = "Test", Email = "test@example.com" }); 45 | db.Stores.Add(new GroceryStore { Id = 1, UserId = 1, Name = "Test", Description = "Test store 1" }); 46 | db.Stores.Add(new GroceryStore { Id = 2, UserId = 1, Name = "Test", Description = "Test store 2" }); 47 | db.Items.Add(new GroceryListItem { Id = 1, UserId = 1, StoreId = 1, Title = "Test 1-1", IsComplete = true }); 48 | db.Items.Add(new GroceryListItem { Id = 2, UserId = 1, StoreId = 1, Title = "Test 1-2", IsComplete = false }); 49 | db.Items.Add(new GroceryListItem { Id = 3, UserId = 1, StoreId = 2, Title = "Test 2-1", IsComplete = true }); 50 | db.SaveChanges(); 51 | } 52 | } 53 | } 54 | 55 | return app; 56 | } 57 | } -------------------------------------------------------------------------------- /GroceryListApi.Tests/ModuleInitializer.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.CompilerServices; 2 | using VerifyTests; 3 | 4 | public static class ModuleInitializer 5 | { 6 | [ModuleInitializer] 7 | public static void Init() 8 | { 9 | VerifyHttp.Initialize(); 10 | VerifierSettings.IgnoreMember("Authorization"); 11 | } 12 | } -------------------------------------------------------------------------------- /GroceryListApi.Tests/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "iisSettings": { 3 | "windowsAuthentication": false, 4 | "anonymousAuthentication": true, 5 | "iisExpress": { 6 | "applicationUrl": "http://localhost:59417/", 7 | "sslPort": 44300 8 | } 9 | }, 10 | "profiles": { 11 | "IIS Express": { 12 | "commandName": "IISExpress", 13 | "launchBrowser": true, 14 | "environmentVariables": { 15 | "ASPNETCORE_ENVIRONMENT": "Development" 16 | } 17 | }, 18 | "GroceryListApi.Tests": { 19 | "commandName": "Project", 20 | "launchBrowser": true, 21 | "environmentVariables": { 22 | "ASPNETCORE_ENVIRONMENT": "Development" 23 | }, 24 | "applicationUrl": "https://localhost:5001;http://localhost:5000" 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /GroceryListApi.Tests/xunit.runner.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", 3 | "parallelizeAssembly": true, 4 | "parallelizeTestCollections": false 5 | } -------------------------------------------------------------------------------- /GroceryListApi.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GroceryListApi", "GroceryListApi\GroceryListApi.csproj", "{449F1D7C-82B6-4172-BF2B-CC834165FD2A}" 4 | EndProject 5 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_", "_", "{80E4A06B-9626-4102-BE7E-617D5E1E4B12}" 6 | EndProject 7 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".config", ".config", "{95D00DD3-D73B-4FA1-AEE1-2AFC7FB49B1B}" 8 | ProjectSection(SolutionItems) = preProject 9 | .config\dotnet-tools.json = .config\dotnet-tools.json 10 | EndProjectSection 11 | EndProject 12 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GroceryListApi.Tests", "GroceryListApi.Tests\GroceryListApi.Tests.csproj", "{AF6D970D-0E5B-49B1-90DF-F632AE5E91B4}" 13 | EndProject 14 | Global 15 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 16 | Debug|Any CPU = Debug|Any CPU 17 | Release|Any CPU = Release|Any CPU 18 | EndGlobalSection 19 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 20 | {449F1D7C-82B6-4172-BF2B-CC834165FD2A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 21 | {449F1D7C-82B6-4172-BF2B-CC834165FD2A}.Debug|Any CPU.Build.0 = Debug|Any CPU 22 | {449F1D7C-82B6-4172-BF2B-CC834165FD2A}.Release|Any CPU.ActiveCfg = Release|Any CPU 23 | {449F1D7C-82B6-4172-BF2B-CC834165FD2A}.Release|Any CPU.Build.0 = Release|Any CPU 24 | {AF6D970D-0E5B-49B1-90DF-F632AE5E91B4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 25 | {AF6D970D-0E5B-49B1-90DF-F632AE5E91B4}.Debug|Any CPU.Build.0 = Debug|Any CPU 26 | {AF6D970D-0E5B-49B1-90DF-F632AE5E91B4}.Release|Any CPU.ActiveCfg = Release|Any CPU 27 | {AF6D970D-0E5B-49B1-90DF-F632AE5E91B4}.Release|Any CPU.Build.0 = Release|Any CPU 28 | EndGlobalSection 29 | GlobalSection(NestedProjects) = preSolution 30 | {95D00DD3-D73B-4FA1-AEE1-2AFC7FB49B1B} = {80E4A06B-9626-4102-BE7E-617D5E1E4B12} 31 | EndGlobalSection 32 | EndGlobal 33 | -------------------------------------------------------------------------------- /GroceryListApi/Authentication/AddAuthorizationHeaderOperationHeader.cs: -------------------------------------------------------------------------------- 1 | using JetBrains.Annotations; 2 | using Microsoft.AspNetCore.Authorization; 3 | using Microsoft.OpenApi.Models; 4 | using Swashbuckle.AspNetCore.SwaggerGen; 5 | 6 | namespace GroceryListApi.Authentication; 7 | 8 | [UsedImplicitly] 9 | public class AddAuthorizationHeaderOperationHeader : IOperationFilter 10 | { 11 | public void Apply(OpenApiOperation operation, OperationFilterContext context) 12 | { 13 | var actionMetadata = context.ApiDescription.ActionDescriptor.EndpointMetadata; 14 | var isAuthorized = actionMetadata.Any(metadataItem => metadataItem is AuthorizeAttribute); 15 | var allowAnonymous = actionMetadata.Any(metadataItem => metadataItem is AllowAnonymousAttribute); 16 | 17 | if (!isAuthorized || allowAnonymous) 18 | { 19 | return; 20 | } 21 | 22 | operation.Parameters ??= new List(); 23 | operation.Security = new List(); 24 | 25 | // Add JWT bearer type 26 | operation.Security.Add(new OpenApiSecurityRequirement { 27 | { 28 | new OpenApiSecurityScheme { 29 | Reference = new OpenApiReference { 30 | Id = "Bearer", 31 | Type = ReferenceType.SecurityScheme 32 | } 33 | }, 34 | new List() 35 | } 36 | } 37 | ); 38 | } 39 | } -------------------------------------------------------------------------------- /GroceryListApi/ClaimsPrincipalExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Claims; 2 | 3 | namespace GroceryListApi; 4 | 5 | public static class ClaimsPrincipalExtensions 6 | { 7 | public static string GetClaimValue(this ClaimsPrincipal principal, string type) 8 | => principal.FindFirst(type)!.Value; 9 | } -------------------------------------------------------------------------------- /GroceryListApi/Database/GroceryListDb.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | 3 | namespace GroceryListApi.Database; 4 | 5 | public class GroceryListDb : DbContext 6 | { 7 | public GroceryListDb(DbContextOptions options) 8 | : base(options) { } 9 | 10 | protected override void OnModelCreating(ModelBuilder modelBuilder) 11 | { 12 | modelBuilder.Entity() 13 | .HasIndex(u => u.Username, "IX_Username"); 14 | 15 | modelBuilder.Entity() 16 | .HasMany(u => u.Stores) 17 | .WithOne(s => s.User) 18 | .HasForeignKey(s => s.UserId); 19 | 20 | modelBuilder.Entity() 21 | .HasMany(u => u.Items) 22 | .WithOne(i => i.User) 23 | .HasForeignKey(i => i.UserId); 24 | 25 | modelBuilder.Entity() 26 | .HasMany(s => s.Items) 27 | .WithOne(i => i.Store) 28 | .HasForeignKey(i => i.StoreId); 29 | 30 | // Seed data 31 | modelBuilder.Entity() 32 | .HasData(new User 33 | { 34 | Id = 1, 35 | Username = "test", 36 | Name = "Test user", 37 | Email = "test@example.org" 38 | }); 39 | 40 | modelBuilder.Entity() 41 | .HasData(new GroceryStore 42 | { 43 | Id = 1, 44 | UserId = 1, 45 | Name = "Example Groceries Inc.", 46 | Description = "An example grocery store." 47 | }); 48 | 49 | modelBuilder.Entity() 50 | .HasData( 51 | new GroceryListItem 52 | { 53 | Id = 1, 54 | UserId = 1, 55 | StoreId = 1, 56 | Title = "Potatoes", 57 | IsComplete = false 58 | }, 59 | new GroceryListItem 60 | { 61 | Id = 2, 62 | UserId = 1, 63 | StoreId = 1, 64 | Title = "Tomatoes", 65 | IsComplete = false 66 | }); 67 | } 68 | 69 | public DbSet Users => Set(); 70 | public DbSet Stores => Set(); 71 | public DbSet Items => Set(); 72 | } 73 | -------------------------------------------------------------------------------- /GroceryListApi/Database/GroceryListItem.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace GroceryListApi.Database; 4 | 5 | public class GroceryListItem 6 | { 7 | public int Id { get; set; } 8 | public int UserId { get; set; } 9 | 10 | public User User { get; set; } = null!; 11 | 12 | public int StoreId { get; set; } 13 | public GroceryStore Store { get; set; } = null!; 14 | 15 | [Required] public string Title { get; set; } = null!; 16 | public bool IsComplete { get; set; } 17 | } -------------------------------------------------------------------------------- /GroceryListApi/Database/GroceryStore.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace GroceryListApi.Database; 4 | 5 | public class GroceryStore 6 | { 7 | public int Id { get; set; } 8 | public int UserId { get; set; } 9 | public User User { get; set; } = null!; 10 | 11 | [Required] public string Name { get; set; } = null!; 12 | public string? Description { get; set; } 13 | 14 | public ICollection Items { get; set; } = null!; 15 | } -------------------------------------------------------------------------------- /GroceryListApi/Database/User.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace GroceryListApi.Database; 4 | 5 | public class User 6 | { 7 | public int Id { get; set; } 8 | [Required] public string Username { get; set; } = null!; 9 | [Required] public string Name { get; set; } = null!; 10 | [Required] public string Email { get; set; } = null!; 11 | 12 | public ICollection Stores { get; set; } = null!; 13 | public ICollection Items { get; set; } = null!; 14 | } -------------------------------------------------------------------------------- /GroceryListApi/Endpoints/AuthenticationEndpoints.cs: -------------------------------------------------------------------------------- 1 | using System.IdentityModel.Tokens.Jwt; 2 | using System.Security.Claims; 3 | using System.Text; 4 | using GroceryListApi.Endpoints.Schemas; 5 | using Microsoft.AspNetCore.Http.HttpResults; 6 | using Microsoft.EntityFrameworkCore; 7 | using Microsoft.IdentityModel.Tokens; 8 | using MiniValidation; 9 | 10 | namespace GroceryListApi.Endpoints; 11 | 12 | public static class AuthenticationEndpoints 13 | { 14 | public static WebApplication MapAuthenticationEndpoints(this WebApplication app) 15 | { 16 | app.MapPost("/token", 17 | async Task, Unauthorized>>( 18 | ApiUser apiUser, 19 | GroceryListDb db, 20 | IConfiguration configuration) => 21 | { 22 | if (!MiniValidator.TryValidate(apiUser, out _)) 23 | { 24 | return Results.Extensions.UnauthorizedTypedResult(); 25 | } 26 | 27 | var user = await db.Users 28 | .AsNoTracking() 29 | .FirstOrDefaultAsync(u => u.Username == apiUser.Username); 30 | if (user == null) 31 | { 32 | return Results.Extensions.UnauthorizedTypedResult(); 33 | } 34 | 35 | var claims = new[] 36 | { 37 | new Claim(JwtRegisteredClaimNames.Sub, user.Username), 38 | new Claim(JwtRegisteredClaimNames.Name, user.Name), 39 | new Claim(JwtRegisteredClaimNames.Email, user.Email) 40 | }; 41 | 42 | var token = new JwtSecurityToken 43 | ( 44 | issuer: configuration["Issuer"], 45 | audience: configuration["Audience"], 46 | claims: claims, 47 | expires: DateTime.UtcNow.AddDays(60), 48 | notBefore: DateTime.UtcNow, 49 | signingCredentials: new SigningCredentials( 50 | new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["SigningKey"] ?? 51 | throw new NullReferenceException( 52 | "The value for 'SigningKey' must be specified in appsettings.json"))), 53 | SecurityAlgorithms.HmacSha256) 54 | ); 55 | 56 | return TypedResults.Ok( 57 | new ApiToken(new JwtSecurityTokenHandler().WriteToken(token))); 58 | }) 59 | .AllowAnonymous() 60 | .WithTags("Authentication"); 61 | 62 | return app; 63 | } 64 | } -------------------------------------------------------------------------------- /GroceryListApi/Endpoints/GroceryListItemEndpoints.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Claims; 2 | using GroceryListApi.Endpoints.Schemas; 3 | using Microsoft.AspNetCore.Http.HttpResults; 4 | using Microsoft.AspNetCore.Mvc; 5 | using Microsoft.EntityFrameworkCore; 6 | using MiniValidation; 7 | 8 | namespace GroceryListApi.Endpoints; 9 | 10 | public static class GroceryListItemEndpoints 11 | { 12 | private const string Tag = "Items"; 13 | private const string ById = "stores/byid/items/byid"; 14 | 15 | public static WebApplication MapGroceryListItemEndpoints(this WebApplication app) 16 | { 17 | var routes = app.MapGroup("/stores/{storeId:int}") 18 | .RequireAuthorization() 19 | .WithTags(Tag); 20 | 21 | routes.MapGet("/items", GetAllItems) 22 | .WithDisplayName("Get all items for a store"); 23 | 24 | routes.MapGet("/items/{itemId}", GetItem) 25 | .WithName(ById) 26 | .WithDisplayName("Get an item by id"); 27 | 28 | routes.MapDelete("/items/{itemId}", DeleteItem) 29 | .WithDisplayName("Delete an item by id"); 30 | 31 | routes.MapPut("/items/{itemId}", UpdateItem) 32 | .Accepts("application/json") 33 | .WithDisplayName("Update an item by id"); 34 | 35 | routes.MapPost("/items", CreateItem) 36 | .Accepts("application/json") 37 | .WithDisplayName("Create an item"); 38 | 39 | return app; 40 | } 41 | 42 | private static async Task>, Unauthorized>> GetAllItems([FromRoute]int storeId, ClaimsPrincipal principal, GroceryListDb db) 43 | { 44 | var userId = int.Parse(principal.GetClaimValue(GroceryClaimTypes.UserId)); 45 | 46 | var items = await db.Items 47 | .Where(i => i.UserId == userId && i.StoreId == storeId) 48 | .AsNoTracking() 49 | .ToListAsync(); 50 | 51 | return TypedResults.Ok(items.Select(item => 52 | new ApiItem(item.Id, item.StoreId, item.Title, item.IsComplete))); 53 | } 54 | 55 | private static async Task, NotFound, Unauthorized>> GetItem([FromRoute]int storeId, [FromRoute]int itemId, ClaimsPrincipal principal, GroceryListDb db) 56 | { 57 | var userId = int.Parse(principal.GetClaimValue(GroceryClaimTypes.UserId)); 58 | 59 | var item = await db.Items 60 | .AsNoTracking() 61 | .FirstOrDefaultAsync(i => i.Id == itemId && i.UserId == userId && i.StoreId == storeId); 62 | 63 | if (item == null) return TypedResults.NotFound(); 64 | 65 | return TypedResults.Ok( 66 | new ApiItem(item.Id, item.StoreId, item.Title, item.IsComplete)); 67 | } 68 | 69 | private static async Task> DeleteItem([FromRoute]int storeId, [FromRoute]int itemId, ClaimsPrincipal principal, GroceryListDb db) 70 | { 71 | var userId = int.Parse(principal.GetClaimValue(GroceryClaimTypes.UserId)); 72 | 73 | var item = await db.Items 74 | .FirstOrDefaultAsync(i => i.Id == itemId && i.UserId == userId && i.StoreId == storeId); 75 | 76 | if (item == null) return TypedResults.NotFound(); 77 | 78 | db.Items.Remove(item); 79 | await db.SaveChangesAsync(); 80 | 81 | return TypedResults.Ok(); 82 | } 83 | 84 | private static async Task, ValidationProblem, NotFound, Unauthorized>> UpdateItem([FromRoute]int storeId, [FromRoute]int itemId, [FromBody]ApiItem apiItem, ClaimsPrincipal principal, GroceryListDb db, HttpContext http, LinkGenerator link) 85 | { 86 | var userId = int.Parse(principal.GetClaimValue(GroceryClaimTypes.UserId)); 87 | 88 | var item = await db.Items 89 | .FirstOrDefaultAsync(i => i.Id == itemId && i.UserId == userId && i.StoreId == storeId); 90 | 91 | if (item == null) return TypedResults.NotFound(); 92 | 93 | if (!MiniValidator.TryValidate(apiItem, out var validationErrors)) 94 | { 95 | return TypedResults.ValidationProblem(validationErrors); 96 | } 97 | 98 | item.Title = apiItem.Title; 99 | item.IsComplete = apiItem.IsComplete; 100 | 101 | await db.SaveChangesAsync(); 102 | 103 | return TypedResults.Accepted( 104 | link.GetUriByName(http, ById, new { storeId = item.StoreId, itemId = item.Id })!, 105 | new ApiItem(item.Id, item.StoreId, item.Title, item.IsComplete)); 106 | } 107 | 108 | private static async Task, ValidationProblem, NotFound, Unauthorized>> CreateItem([FromRoute]int storeId, [FromBody]ApiItem apiItem, ClaimsPrincipal principal, GroceryListDb db, HttpContext http, LinkGenerator link) 109 | { 110 | var userId = int.Parse(principal.GetClaimValue(GroceryClaimTypes.UserId)); 111 | 112 | var store = await db.Stores 113 | .FirstOrDefaultAsync(s => s.Id == storeId && s.UserId == userId); 114 | 115 | if (store == null) return TypedResults.NotFound(); 116 | 117 | if (!MiniValidator.TryValidate(apiItem, out var validationErrors)) 118 | { 119 | return TypedResults.ValidationProblem(validationErrors); 120 | } 121 | 122 | var item = new GroceryListItem 123 | { 124 | UserId = userId, 125 | StoreId = storeId, 126 | Title = apiItem.Title, 127 | IsComplete = apiItem.IsComplete 128 | }; 129 | db.Items.Add(item); 130 | await db.SaveChangesAsync(); 131 | 132 | return TypedResults.Created( 133 | link.GetUriByName(http, ById, new { storeId = item.StoreId, itemId = item.Id })!, 134 | new ApiItem(item.Id, item.StoreId, item.Title, item.IsComplete)); 135 | } 136 | } -------------------------------------------------------------------------------- /GroceryListApi/Endpoints/GroceryStoreEndpoints.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Claims; 2 | using GroceryListApi.Endpoints.Schemas; 3 | using Microsoft.AspNetCore.Http.HttpResults; 4 | using Microsoft.AspNetCore.Mvc; 5 | using Microsoft.EntityFrameworkCore; 6 | using MiniValidation; 7 | // ReSharper disable RedundantArgumentDefaultValue 8 | 9 | namespace GroceryListApi.Endpoints; 10 | 11 | public static class GroceryStoreEndpoints 12 | { 13 | private const string Tag = "Stores"; 14 | private const string ById = "stores/byid"; 15 | 16 | public static WebApplication MapGroceryStoreEndpoints(this WebApplication app) 17 | { 18 | var routes = app.MapGroup("/stores") 19 | .RequireAuthorization() 20 | .WithTags(Tag); 21 | 22 | routes.MapGet("/", GetAllStores) 23 | .WithDisplayName("Get all stores"); 24 | 25 | routes.MapGet("/{storeId:int}", GetStore) 26 | .WithName(ById) 27 | .WithDisplayName("Get a store by id"); 28 | 29 | routes.MapDelete("/{storeId:int}", DeleteStore) 30 | .WithDisplayName("Delete a store by id"); 31 | 32 | routes.MapPut("/{storeId:int}", UpdateStore) 33 | .Accepts("application/json") 34 | .WithDisplayName("Update a store by id"); 35 | 36 | routes.MapPost("/", CreateStore) 37 | .Accepts("application/json") 38 | .WithDisplayName("Create a store"); 39 | 40 | return app; 41 | } 42 | 43 | private static async Task>, Unauthorized>> GetAllStores(ClaimsPrincipal principal, GroceryListDb db) 44 | { 45 | var userId = int.Parse(principal.GetClaimValue(GroceryClaimTypes.UserId)); 46 | 47 | // var stores = await db.Stores 48 | // .FromSqlInterpolated($"SELECT * FROM Stores WHERE UserId = '{userId}'") 49 | // .ToListAsync(); 50 | 51 | var stores = await db.Stores 52 | .Where(s => s.UserId == userId) 53 | .AsNoTracking() 54 | .ToListAsync(); 55 | 56 | return TypedResults.Ok(stores.Select(store => 57 | new ApiStore(store.Id, store.Name, store.Description))); 58 | } 59 | 60 | private static async Task, NotFound, Unauthorized>> GetStore([FromRoute]int storeId, ClaimsPrincipal principal, GroceryListDb db) 61 | { 62 | var userId = int.Parse(principal.GetClaimValue(GroceryClaimTypes.UserId)); 63 | 64 | var store = await db.Stores 65 | .AsNoTracking() 66 | .FirstOrDefaultAsync(s => s.Id == storeId && s.UserId == userId); 67 | 68 | if (store == null) return TypedResults.NotFound(); 69 | 70 | return TypedResults.Ok( 71 | new ApiStore(store.Id, store.Name, store.Description)); 72 | } 73 | 74 | private static async Task> DeleteStore([FromRoute]int storeId, ClaimsPrincipal principal, GroceryListDb db) 75 | { 76 | var userId = int.Parse(principal.GetClaimValue(GroceryClaimTypes.UserId)); 77 | 78 | var store = await db.Stores 79 | .FirstOrDefaultAsync(s => s.Id == storeId && s.UserId == userId); 80 | 81 | if (store == null) return TypedResults.NotFound(); 82 | 83 | db.Stores.Remove(store); 84 | await db.SaveChangesAsync(); 85 | 86 | return TypedResults.Ok(); 87 | } 88 | 89 | private static async Task, ValidationProblem, NotFound, Unauthorized>> UpdateStore([FromRoute]int storeId, [FromBody]ApiStore apiStore, ClaimsPrincipal principal, GroceryListDb db, HttpContext http, LinkGenerator link) 90 | { 91 | var userId = int.Parse(principal.GetClaimValue(GroceryClaimTypes.UserId)); 92 | 93 | var store = await db.Stores 94 | .FirstOrDefaultAsync(s => s.Id == storeId && s.UserId == userId); 95 | 96 | if (store == null) return TypedResults.NotFound(); 97 | 98 | if (!MiniValidator.TryValidate(apiStore, out var validationErrors)) 99 | { 100 | return TypedResults.ValidationProblem(validationErrors); 101 | } 102 | 103 | store.Name = apiStore.Name; 104 | store.Description = apiStore.Description; 105 | await db.SaveChangesAsync(); 106 | 107 | return TypedResults.Accepted( 108 | link.GetUriByName(http, ById, new { storeId = store.Id })!, 109 | new ApiStore(store.Id, store.Name, store.Description)); 110 | } 111 | 112 | private static async Task, ValidationProblem, NotFound, Unauthorized>> CreateStore([FromBody]ApiStore apiStore, ClaimsPrincipal principal, GroceryListDb db, HttpContext http, LinkGenerator link) 113 | { 114 | if (!MiniValidator.TryValidate(apiStore, out var validationErrors)) 115 | { 116 | return TypedResults.ValidationProblem(validationErrors); 117 | } 118 | 119 | var userId = int.Parse(principal.GetClaimValue(GroceryClaimTypes.UserId)); 120 | var store = new GroceryStore 121 | { 122 | UserId = userId, 123 | Name = apiStore.Name, 124 | Description = apiStore.Description 125 | }; 126 | db.Stores.Add(store); 127 | await db.SaveChangesAsync(); 128 | 129 | return TypedResults.Created( 130 | link.GetUriByName(http, ById, new { storeId = store.Id })!, 131 | new ApiStore(store.Id, store.Name, store.Description)); 132 | } 133 | } -------------------------------------------------------------------------------- /GroceryListApi/Endpoints/Schemas/ApiItem.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace GroceryListApi.Endpoints.Schemas; 4 | 5 | public record ApiItem( 6 | int? Id, 7 | int? StoreId, 8 | [property:Required] string Title, 9 | bool IsComplete); -------------------------------------------------------------------------------- /GroceryListApi/Endpoints/Schemas/ApiStore.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace GroceryListApi.Endpoints.Schemas; 4 | 5 | public record ApiStore( 6 | int? Id, 7 | [property:Required] string Name, 8 | string? Description); -------------------------------------------------------------------------------- /GroceryListApi/Endpoints/Schemas/ApiToken.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace GroceryListApi.Endpoints.Schemas; 4 | 5 | public record ApiToken( 6 | [property:Required]string Token); -------------------------------------------------------------------------------- /GroceryListApi/Endpoints/Schemas/ApiUser.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace GroceryListApi.Endpoints.Schemas; 4 | 5 | public record ApiUser( 6 | [property:Required]string Username, 7 | [property:Required]string Password); -------------------------------------------------------------------------------- /GroceryListApi/Endpoints/SwaggerEndpoints.cs: -------------------------------------------------------------------------------- 1 | namespace GroceryListApi.Endpoints; 2 | 3 | public static class SwaggerEndpoints 4 | { 5 | public static WebApplication MapSwaggerEndpoints(this WebApplication app) 6 | { 7 | if (app.Environment.IsDevelopment()) 8 | { 9 | app.UseSwagger(); 10 | app.UseSwaggerUI(); 11 | } 12 | 13 | return app; 14 | } 15 | } -------------------------------------------------------------------------------- /GroceryListApi/GroceryClaimTypes.cs: -------------------------------------------------------------------------------- 1 | namespace GroceryListApi; 2 | 3 | public static class GroceryClaimTypes 4 | { 5 | public const string UserId = "grocerylistapi:id"; 6 | } -------------------------------------------------------------------------------- /GroceryListApi/GroceryListApi.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | Linux 8 | 9 | 10 | 11 | 12 | 13 | 14 | all 15 | runtime; build; native; contentfiles; analyzers; buildtransitive 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /GroceryListApi/GroceryListApi.csproj.DotSettings: -------------------------------------------------------------------------------- 1 |  2 | False -------------------------------------------------------------------------------- /GroceryListApi/HttpResults/Unauthorized.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using JetBrains.Annotations; 3 | using Microsoft.AspNetCore.Http.Metadata; 4 | 5 | // ReSharper disable once CheckNamespace 6 | namespace Microsoft.AspNetCore.Http.HttpResults; 7 | 8 | /// 9 | /// An that on execution will write an object to the response 10 | /// with Unauthorized (401) status code. 11 | /// 12 | // HACK: The UnauthorizedHttpResult class is sealed, so can't extend it here. 13 | // I have not investigated if this alternative has any side effects, so please use this with caution. 14 | // public sealed class Unauthorized : UnauthorizedHttpResult, IResult, IEndpointMetadataProvider, IStatusCodeHttpResult 15 | // 16 | // Alternative: 17 | [PublicAPI] 18 | public sealed class Unauthorized : IResult, IEndpointMetadataProvider, IStatusCodeHttpResult 19 | { 20 | /// 21 | /// Initializes a new instance of the class with the values. 22 | /// 23 | internal Unauthorized() 24 | { 25 | } 26 | 27 | /// 28 | /// Gets the HTTP status code: 29 | /// 30 | public int StatusCode => StatusCodes.Status401Unauthorized; 31 | 32 | int? IStatusCodeHttpResult.StatusCode => StatusCode; 33 | 34 | /// 35 | public Task ExecuteAsync(HttpContext httpContext) 36 | { 37 | ArgumentNullException.ThrowIfNull(httpContext); 38 | 39 | // Creating the logger with a string to preserve the category after the refactoring. 40 | var loggerFactory = httpContext.RequestServices.GetRequiredService(); 41 | var logger = loggerFactory.CreateLogger("Microsoft.AspNetCore.Http.Result.UnauthorizedHttpResult"); 42 | 43 | // HACK: The HttpResultsHelper class is internal, so can't use it here. 44 | // HttpResultsHelper.Log.WritingResultAsStatusCode(logger, StatusCode); 45 | // 46 | // Alternative: 47 | // ReSharper disable once LogMessageIsSentenceProblem 48 | logger.LogInformation("Setting HTTP status code {StatusCode}.", StatusCode); 49 | 50 | httpContext.Response.StatusCode = StatusCode; 51 | 52 | return Task.CompletedTask; 53 | } 54 | 55 | /// 56 | static void IEndpointMetadataProvider.PopulateMetadata(MethodInfo method, EndpointBuilder builder) 57 | { 58 | ArgumentNullException.ThrowIfNull(method); 59 | ArgumentNullException.ThrowIfNull(builder); 60 | 61 | // HACK: The ProducesResponseTypeMetadata class is internal, so can't use it here. 62 | // builder.Metadata.Add(new ProducesResponseTypeMetadata(StatusCodes.Status401Unauthorized)); 63 | // 64 | // Alternative: 65 | builder.Metadata.Add(new UnauthorizedResponseTypeMetadata()); 66 | } 67 | 68 | private class UnauthorizedResponseTypeMetadata : IProducesResponseTypeMetadata 69 | { 70 | public Type? Type => typeof(void); 71 | public int StatusCode => 401; 72 | public IEnumerable ContentTypes => Enumerable.Empty(); 73 | } 74 | } 75 | 76 | // HACK: Extending IResultExtensions so that we can use this alternative Unauthorized result. 77 | // Ideally, TypedResults.Unauthorized() would provide response type metadata, but alas. 78 | public static class UnauthorizedResultExtensions 79 | { 80 | private static readonly Unauthorized Unauthorized = new(); 81 | 82 | public static Unauthorized UnauthorizedTypedResult(this IResultExtensions current) => Unauthorized; 83 | } -------------------------------------------------------------------------------- /GroceryListApi/Migrations/20211124165140_Initial.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | using GroceryListApi.Database; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore.Infrastructure; 5 | using Microsoft.EntityFrameworkCore.Migrations; 6 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 7 | 8 | #nullable disable 9 | 10 | namespace GroceryListApi.Migrations 11 | { 12 | [DbContext(typeof(GroceryListDb))] 13 | [Migration("20211124165140_Initial")] 14 | partial class Initial 15 | { 16 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 17 | { 18 | #pragma warning disable 612, 618 19 | modelBuilder.HasAnnotation("ProductVersion", "6.0.0"); 20 | 21 | modelBuilder.Entity("GroceryListApi.Database.GroceryListItem", b => 22 | { 23 | b.Property("Id") 24 | .ValueGeneratedOnAdd() 25 | .HasColumnType("INTEGER"); 26 | 27 | b.Property("IsComplete") 28 | .HasColumnType("INTEGER"); 29 | 30 | b.Property("Title") 31 | .IsRequired() 32 | .HasColumnType("TEXT"); 33 | 34 | b.HasKey("Id"); 35 | 36 | b.ToTable("Items"); 37 | }); 38 | #pragma warning restore 612, 618 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /GroceryListApi/Migrations/20211124165140_Initial.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Migrations; 2 | 3 | #nullable disable 4 | 5 | namespace GroceryListApi.Migrations 6 | { 7 | public partial class Initial : Migration 8 | { 9 | protected override void Up(MigrationBuilder migrationBuilder) 10 | { 11 | migrationBuilder.CreateTable( 12 | name: "Items", 13 | columns: table => new 14 | { 15 | Id = table.Column(type: "INTEGER", nullable: false) 16 | .Annotation("Sqlite:Autoincrement", true), 17 | Title = table.Column(type: "TEXT", nullable: false), 18 | IsComplete = table.Column(type: "INTEGER", nullable: false) 19 | }, 20 | constraints: table => 21 | { 22 | table.PrimaryKey("PK_Items", x => x.Id); 23 | }); 24 | } 25 | 26 | protected override void Down(MigrationBuilder migrationBuilder) 27 | { 28 | migrationBuilder.DropTable( 29 | name: "Items"); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /GroceryListApi/Migrations/20211230091359_IntroduceGroceryStore.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | using GroceryListApi.Database; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore.Infrastructure; 5 | using Microsoft.EntityFrameworkCore.Migrations; 6 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 7 | 8 | #nullable disable 9 | 10 | namespace GroceryListApi.Migrations 11 | { 12 | [DbContext(typeof(GroceryListDb))] 13 | [Migration("20211230091359_IntroduceGroceryStore")] 14 | partial class IntroduceGroceryStore 15 | { 16 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 17 | { 18 | #pragma warning disable 612, 618 19 | modelBuilder.HasAnnotation("ProductVersion", "6.0.0"); 20 | 21 | modelBuilder.Entity("GroceryListApi.Database.GroceryListItem", b => 22 | { 23 | b.Property("Id") 24 | .ValueGeneratedOnAdd() 25 | .HasColumnType("INTEGER"); 26 | 27 | b.Property("IsComplete") 28 | .HasColumnType("INTEGER"); 29 | 30 | b.Property("Title") 31 | .IsRequired() 32 | .HasColumnType("TEXT"); 33 | 34 | b.HasKey("Id"); 35 | 36 | b.ToTable("Items"); 37 | }); 38 | 39 | modelBuilder.Entity("GroceryListApi.Database.GroceryStore", b => 40 | { 41 | b.Property("Id") 42 | .ValueGeneratedOnAdd() 43 | .HasColumnType("INTEGER"); 44 | 45 | b.Property("Description") 46 | .HasColumnType("TEXT"); 47 | 48 | b.Property("Name") 49 | .IsRequired() 50 | .HasColumnType("TEXT"); 51 | 52 | b.HasKey("Id"); 53 | 54 | b.ToTable("Stores"); 55 | }); 56 | 57 | modelBuilder.Entity("GroceryListApi.Database.User", b => 58 | { 59 | b.Property("Id") 60 | .ValueGeneratedOnAdd() 61 | .HasColumnType("INTEGER"); 62 | 63 | b.Property("Username") 64 | .IsRequired() 65 | .HasColumnType("TEXT"); 66 | 67 | b.HasKey("Id"); 68 | 69 | b.ToTable("Users"); 70 | }); 71 | #pragma warning restore 612, 618 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /GroceryListApi/Migrations/20211230091359_IntroduceGroceryStore.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Migrations; 2 | 3 | #nullable disable 4 | 5 | namespace GroceryListApi.Migrations 6 | { 7 | public partial class IntroduceGroceryStore : Migration 8 | { 9 | protected override void Up(MigrationBuilder migrationBuilder) 10 | { 11 | migrationBuilder.CreateTable( 12 | name: "Stores", 13 | columns: table => new 14 | { 15 | Id = table.Column(type: "INTEGER", nullable: false) 16 | .Annotation("Sqlite:Autoincrement", true), 17 | Name = table.Column(type: "TEXT", nullable: false), 18 | Description = table.Column(type: "TEXT", nullable: true) 19 | }, 20 | constraints: table => 21 | { 22 | table.PrimaryKey("PK_Stores", x => x.Id); 23 | }); 24 | 25 | migrationBuilder.CreateTable( 26 | name: "Users", 27 | columns: table => new 28 | { 29 | Id = table.Column(type: "INTEGER", nullable: false) 30 | .Annotation("Sqlite:Autoincrement", true), 31 | Username = table.Column(type: "TEXT", nullable: false) 32 | }, 33 | constraints: table => 34 | { 35 | table.PrimaryKey("PK_Users", x => x.Id); 36 | }); 37 | } 38 | 39 | protected override void Down(MigrationBuilder migrationBuilder) 40 | { 41 | migrationBuilder.DropTable( 42 | name: "Stores"); 43 | 44 | migrationBuilder.DropTable( 45 | name: "Users"); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /GroceryListApi/Migrations/20211230093751_ImproveDatabaseModel.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | using GroceryListApi.Database; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore.Infrastructure; 5 | using Microsoft.EntityFrameworkCore.Migrations; 6 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 7 | 8 | #nullable disable 9 | 10 | namespace GroceryListApi.Migrations 11 | { 12 | [DbContext(typeof(GroceryListDb))] 13 | [Migration("20211230093751_ImproveDatabaseModel")] 14 | partial class ImproveDatabaseModel 15 | { 16 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 17 | { 18 | #pragma warning disable 612, 618 19 | modelBuilder.HasAnnotation("ProductVersion", "6.0.0"); 20 | 21 | modelBuilder.Entity("GroceryListApi.Database.GroceryListItem", b => 22 | { 23 | b.Property("Id") 24 | .ValueGeneratedOnAdd() 25 | .HasColumnType("INTEGER"); 26 | 27 | b.Property("IsComplete") 28 | .HasColumnType("INTEGER"); 29 | 30 | b.Property("StoreId") 31 | .HasColumnType("INTEGER"); 32 | 33 | b.Property("Title") 34 | .IsRequired() 35 | .HasColumnType("TEXT"); 36 | 37 | b.Property("UserId") 38 | .HasColumnType("INTEGER"); 39 | 40 | b.HasKey("Id"); 41 | 42 | b.HasIndex("StoreId"); 43 | 44 | b.HasIndex("UserId"); 45 | 46 | b.ToTable("Items"); 47 | }); 48 | 49 | modelBuilder.Entity("GroceryListApi.Database.GroceryStore", b => 50 | { 51 | b.Property("Id") 52 | .ValueGeneratedOnAdd() 53 | .HasColumnType("INTEGER"); 54 | 55 | b.Property("Description") 56 | .HasColumnType("TEXT"); 57 | 58 | b.Property("Name") 59 | .IsRequired() 60 | .HasColumnType("TEXT"); 61 | 62 | b.Property("UserId") 63 | .HasColumnType("INTEGER"); 64 | 65 | b.HasKey("Id"); 66 | 67 | b.HasIndex("UserId"); 68 | 69 | b.ToTable("Stores"); 70 | }); 71 | 72 | modelBuilder.Entity("GroceryListApi.Database.User", b => 73 | { 74 | b.Property("Id") 75 | .ValueGeneratedOnAdd() 76 | .HasColumnType("INTEGER"); 77 | 78 | b.Property("Email") 79 | .IsRequired() 80 | .HasColumnType("TEXT"); 81 | 82 | b.Property("Name") 83 | .IsRequired() 84 | .HasColumnType("TEXT"); 85 | 86 | b.Property("Username") 87 | .IsRequired() 88 | .HasColumnType("TEXT"); 89 | 90 | b.HasKey("Id"); 91 | 92 | b.HasIndex(new[] { "Username" }, "IX_Username"); 93 | 94 | b.ToTable("Users"); 95 | }); 96 | 97 | modelBuilder.Entity("GroceryListApi.Database.GroceryListItem", b => 98 | { 99 | b.HasOne("GroceryListApi.Database.GroceryStore", "Store") 100 | .WithMany("Items") 101 | .HasForeignKey("StoreId") 102 | .OnDelete(DeleteBehavior.Cascade) 103 | .IsRequired(); 104 | 105 | b.HasOne("GroceryListApi.Database.User", "User") 106 | .WithMany("Items") 107 | .HasForeignKey("UserId") 108 | .OnDelete(DeleteBehavior.Cascade) 109 | .IsRequired(); 110 | 111 | b.Navigation("Store"); 112 | 113 | b.Navigation("User"); 114 | }); 115 | 116 | modelBuilder.Entity("GroceryListApi.Database.GroceryStore", b => 117 | { 118 | b.HasOne("GroceryListApi.Database.User", "User") 119 | .WithMany("Stores") 120 | .HasForeignKey("UserId") 121 | .OnDelete(DeleteBehavior.Cascade) 122 | .IsRequired(); 123 | 124 | b.Navigation("User"); 125 | }); 126 | 127 | modelBuilder.Entity("GroceryListApi.Database.GroceryStore", b => 128 | { 129 | b.Navigation("Items"); 130 | }); 131 | 132 | modelBuilder.Entity("GroceryListApi.Database.User", b => 133 | { 134 | b.Navigation("Items"); 135 | 136 | b.Navigation("Stores"); 137 | }); 138 | #pragma warning restore 612, 618 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /GroceryListApi/Migrations/20211230093751_ImproveDatabaseModel.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Migrations; 2 | 3 | #nullable disable 4 | 5 | namespace GroceryListApi.Migrations 6 | { 7 | public partial class ImproveDatabaseModel : Migration 8 | { 9 | protected override void Up(MigrationBuilder migrationBuilder) 10 | { 11 | migrationBuilder.AddColumn( 12 | name: "Email", 13 | table: "Users", 14 | type: "TEXT", 15 | nullable: false, 16 | defaultValue: ""); 17 | 18 | migrationBuilder.AddColumn( 19 | name: "Name", 20 | table: "Users", 21 | type: "TEXT", 22 | nullable: false, 23 | defaultValue: ""); 24 | 25 | migrationBuilder.AddColumn( 26 | name: "UserId", 27 | table: "Stores", 28 | type: "INTEGER", 29 | nullable: false, 30 | defaultValue: 0); 31 | 32 | migrationBuilder.AddColumn( 33 | name: "StoreId", 34 | table: "Items", 35 | type: "INTEGER", 36 | nullable: false, 37 | defaultValue: 0); 38 | 39 | migrationBuilder.AddColumn( 40 | name: "UserId", 41 | table: "Items", 42 | type: "INTEGER", 43 | nullable: false, 44 | defaultValue: 0); 45 | 46 | migrationBuilder.CreateIndex( 47 | name: "IX_Username", 48 | table: "Users", 49 | column: "Username"); 50 | 51 | migrationBuilder.CreateIndex( 52 | name: "IX_Stores_UserId", 53 | table: "Stores", 54 | column: "UserId"); 55 | 56 | migrationBuilder.CreateIndex( 57 | name: "IX_Items_StoreId", 58 | table: "Items", 59 | column: "StoreId"); 60 | 61 | migrationBuilder.CreateIndex( 62 | name: "IX_Items_UserId", 63 | table: "Items", 64 | column: "UserId"); 65 | 66 | migrationBuilder.AddForeignKey( 67 | name: "FK_Items_Stores_StoreId", 68 | table: "Items", 69 | column: "StoreId", 70 | principalTable: "Stores", 71 | principalColumn: "Id", 72 | onDelete: ReferentialAction.Cascade); 73 | 74 | migrationBuilder.AddForeignKey( 75 | name: "FK_Items_Users_UserId", 76 | table: "Items", 77 | column: "UserId", 78 | principalTable: "Users", 79 | principalColumn: "Id", 80 | onDelete: ReferentialAction.Cascade); 81 | 82 | migrationBuilder.AddForeignKey( 83 | name: "FK_Stores_Users_UserId", 84 | table: "Stores", 85 | column: "UserId", 86 | principalTable: "Users", 87 | principalColumn: "Id", 88 | onDelete: ReferentialAction.Cascade); 89 | } 90 | 91 | protected override void Down(MigrationBuilder migrationBuilder) 92 | { 93 | migrationBuilder.DropForeignKey( 94 | name: "FK_Items_Stores_StoreId", 95 | table: "Items"); 96 | 97 | migrationBuilder.DropForeignKey( 98 | name: "FK_Items_Users_UserId", 99 | table: "Items"); 100 | 101 | migrationBuilder.DropForeignKey( 102 | name: "FK_Stores_Users_UserId", 103 | table: "Stores"); 104 | 105 | migrationBuilder.DropIndex( 106 | name: "IX_Username", 107 | table: "Users"); 108 | 109 | migrationBuilder.DropIndex( 110 | name: "IX_Stores_UserId", 111 | table: "Stores"); 112 | 113 | migrationBuilder.DropIndex( 114 | name: "IX_Items_StoreId", 115 | table: "Items"); 116 | 117 | migrationBuilder.DropIndex( 118 | name: "IX_Items_UserId", 119 | table: "Items"); 120 | 121 | migrationBuilder.DropColumn( 122 | name: "Email", 123 | table: "Users"); 124 | 125 | migrationBuilder.DropColumn( 126 | name: "Name", 127 | table: "Users"); 128 | 129 | migrationBuilder.DropColumn( 130 | name: "UserId", 131 | table: "Stores"); 132 | 133 | migrationBuilder.DropColumn( 134 | name: "StoreId", 135 | table: "Items"); 136 | 137 | migrationBuilder.DropColumn( 138 | name: "UserId", 139 | table: "Items"); 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /GroceryListApi/Migrations/20221027065018_SeedData.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | using GroceryListApi.Database; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore.Infrastructure; 5 | using Microsoft.EntityFrameworkCore.Migrations; 6 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 7 | 8 | #nullable disable 9 | 10 | namespace GroceryListApi.Migrations 11 | { 12 | [DbContext(typeof(GroceryListDb))] 13 | [Migration("20221027065018_SeedData")] 14 | partial class SeedData 15 | { 16 | /// 17 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 18 | { 19 | #pragma warning disable 612, 618 20 | modelBuilder.HasAnnotation("ProductVersion", "7.0.0-rc.2.22472.11"); 21 | 22 | modelBuilder.Entity("GroceryListApi.Database.GroceryListItem", b => 23 | { 24 | b.Property("Id") 25 | .ValueGeneratedOnAdd() 26 | .HasColumnType("INTEGER"); 27 | 28 | b.Property("IsComplete") 29 | .HasColumnType("INTEGER"); 30 | 31 | b.Property("StoreId") 32 | .HasColumnType("INTEGER"); 33 | 34 | b.Property("Title") 35 | .IsRequired() 36 | .HasColumnType("TEXT"); 37 | 38 | b.Property("UserId") 39 | .HasColumnType("INTEGER"); 40 | 41 | b.HasKey("Id"); 42 | 43 | b.HasIndex("StoreId"); 44 | 45 | b.HasIndex("UserId"); 46 | 47 | b.ToTable("Items"); 48 | 49 | b.HasData( 50 | new 51 | { 52 | Id = 1, 53 | IsComplete = false, 54 | StoreId = 1, 55 | Title = "Potatoes", 56 | UserId = 1 57 | }, 58 | new 59 | { 60 | Id = 2, 61 | IsComplete = false, 62 | StoreId = 1, 63 | Title = "Tomatoes", 64 | UserId = 1 65 | }); 66 | }); 67 | 68 | modelBuilder.Entity("GroceryListApi.Database.GroceryStore", b => 69 | { 70 | b.Property("Id") 71 | .ValueGeneratedOnAdd() 72 | .HasColumnType("INTEGER"); 73 | 74 | b.Property("Description") 75 | .HasColumnType("TEXT"); 76 | 77 | b.Property("Name") 78 | .IsRequired() 79 | .HasColumnType("TEXT"); 80 | 81 | b.Property("UserId") 82 | .HasColumnType("INTEGER"); 83 | 84 | b.HasKey("Id"); 85 | 86 | b.HasIndex("UserId"); 87 | 88 | b.ToTable("Stores"); 89 | 90 | b.HasData( 91 | new 92 | { 93 | Id = 1, 94 | Description = "An example grocery store.", 95 | Name = "Example Groceries Inc.", 96 | UserId = 1 97 | }); 98 | }); 99 | 100 | modelBuilder.Entity("GroceryListApi.Database.User", b => 101 | { 102 | b.Property("Id") 103 | .ValueGeneratedOnAdd() 104 | .HasColumnType("INTEGER"); 105 | 106 | b.Property("Email") 107 | .IsRequired() 108 | .HasColumnType("TEXT"); 109 | 110 | b.Property("Name") 111 | .IsRequired() 112 | .HasColumnType("TEXT"); 113 | 114 | b.Property("Username") 115 | .IsRequired() 116 | .HasColumnType("TEXT"); 117 | 118 | b.HasKey("Id"); 119 | 120 | b.HasIndex(new[] { "Username" }, "IX_Username"); 121 | 122 | b.ToTable("Users"); 123 | 124 | b.HasData( 125 | new 126 | { 127 | Id = 1, 128 | Email = "test@example.org", 129 | Name = "Test user", 130 | Username = "test" 131 | }); 132 | }); 133 | 134 | modelBuilder.Entity("GroceryListApi.Database.GroceryListItem", b => 135 | { 136 | b.HasOne("GroceryListApi.Database.GroceryStore", "Store") 137 | .WithMany("Items") 138 | .HasForeignKey("StoreId") 139 | .OnDelete(DeleteBehavior.Cascade) 140 | .IsRequired(); 141 | 142 | b.HasOne("GroceryListApi.Database.User", "User") 143 | .WithMany("Items") 144 | .HasForeignKey("UserId") 145 | .OnDelete(DeleteBehavior.Cascade) 146 | .IsRequired(); 147 | 148 | b.Navigation("Store"); 149 | 150 | b.Navigation("User"); 151 | }); 152 | 153 | modelBuilder.Entity("GroceryListApi.Database.GroceryStore", b => 154 | { 155 | b.HasOne("GroceryListApi.Database.User", "User") 156 | .WithMany("Stores") 157 | .HasForeignKey("UserId") 158 | .OnDelete(DeleteBehavior.Cascade) 159 | .IsRequired(); 160 | 161 | b.Navigation("User"); 162 | }); 163 | 164 | modelBuilder.Entity("GroceryListApi.Database.GroceryStore", b => 165 | { 166 | b.Navigation("Items"); 167 | }); 168 | 169 | modelBuilder.Entity("GroceryListApi.Database.User", b => 170 | { 171 | b.Navigation("Items"); 172 | 173 | b.Navigation("Stores"); 174 | }); 175 | #pragma warning restore 612, 618 176 | } 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /GroceryListApi/Migrations/20221027065018_SeedData.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Migrations; 2 | 3 | #nullable disable 4 | 5 | #pragma warning disable CA1814 // Prefer jagged arrays over multidimensional 6 | 7 | namespace GroceryListApi.Migrations 8 | { 9 | /// 10 | public partial class SeedData : Migration 11 | { 12 | /// 13 | protected override void Up(MigrationBuilder migrationBuilder) 14 | { 15 | migrationBuilder.InsertData( 16 | table: "Users", 17 | columns: new[] { "Id", "Email", "Name", "Username" }, 18 | values: new object[] { 1, "test@example.org", "Test user", "test" }); 19 | 20 | migrationBuilder.InsertData( 21 | table: "Stores", 22 | columns: new[] { "Id", "Description", "Name", "UserId" }, 23 | values: new object[] { 1, "An example grocery store.", "Example Groceries Inc.", 1 }); 24 | 25 | migrationBuilder.InsertData( 26 | table: "Items", 27 | columns: new[] { "Id", "IsComplete", "StoreId", "Title", "UserId" }, 28 | values: new object[,] 29 | { 30 | { 1, false, 1, "Potatoes", 1 }, 31 | { 2, false, 1, "Tomatoes", 1 } 32 | }); 33 | } 34 | 35 | /// 36 | protected override void Down(MigrationBuilder migrationBuilder) 37 | { 38 | migrationBuilder.DeleteData( 39 | table: "Items", 40 | keyColumn: "Id", 41 | keyValue: 1); 42 | 43 | migrationBuilder.DeleteData( 44 | table: "Items", 45 | keyColumn: "Id", 46 | keyValue: 2); 47 | 48 | migrationBuilder.DeleteData( 49 | table: "Stores", 50 | keyColumn: "Id", 51 | keyValue: 1); 52 | 53 | migrationBuilder.DeleteData( 54 | table: "Users", 55 | keyColumn: "Id", 56 | keyValue: 1); 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /GroceryListApi/Migrations/GroceryListDbModelSnapshot.cs: -------------------------------------------------------------------------------- 1 | // 2 | using GroceryListApi.Database; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore.Infrastructure; 5 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 6 | 7 | #nullable disable 8 | 9 | namespace GroceryListApi.Migrations 10 | { 11 | [DbContext(typeof(GroceryListDb))] 12 | partial class GroceryListDbModelSnapshot : ModelSnapshot 13 | { 14 | protected override void BuildModel(ModelBuilder modelBuilder) 15 | { 16 | #pragma warning disable 612, 618 17 | modelBuilder.HasAnnotation("ProductVersion", "7.0.0-rc.2.22472.11"); 18 | 19 | modelBuilder.Entity("GroceryListApi.Database.GroceryListItem", b => 20 | { 21 | b.Property("Id") 22 | .ValueGeneratedOnAdd() 23 | .HasColumnType("INTEGER"); 24 | 25 | b.Property("IsComplete") 26 | .HasColumnType("INTEGER"); 27 | 28 | b.Property("StoreId") 29 | .HasColumnType("INTEGER"); 30 | 31 | b.Property("Title") 32 | .IsRequired() 33 | .HasColumnType("TEXT"); 34 | 35 | b.Property("UserId") 36 | .HasColumnType("INTEGER"); 37 | 38 | b.HasKey("Id"); 39 | 40 | b.HasIndex("StoreId"); 41 | 42 | b.HasIndex("UserId"); 43 | 44 | b.ToTable("Items"); 45 | 46 | b.HasData( 47 | new 48 | { 49 | Id = 1, 50 | IsComplete = false, 51 | StoreId = 1, 52 | Title = "Potatoes", 53 | UserId = 1 54 | }, 55 | new 56 | { 57 | Id = 2, 58 | IsComplete = false, 59 | StoreId = 1, 60 | Title = "Tomatoes", 61 | UserId = 1 62 | }); 63 | }); 64 | 65 | modelBuilder.Entity("GroceryListApi.Database.GroceryStore", b => 66 | { 67 | b.Property("Id") 68 | .ValueGeneratedOnAdd() 69 | .HasColumnType("INTEGER"); 70 | 71 | b.Property("Description") 72 | .HasColumnType("TEXT"); 73 | 74 | b.Property("Name") 75 | .IsRequired() 76 | .HasColumnType("TEXT"); 77 | 78 | b.Property("UserId") 79 | .HasColumnType("INTEGER"); 80 | 81 | b.HasKey("Id"); 82 | 83 | b.HasIndex("UserId"); 84 | 85 | b.ToTable("Stores"); 86 | 87 | b.HasData( 88 | new 89 | { 90 | Id = 1, 91 | Description = "An example grocery store.", 92 | Name = "Example Groceries Inc.", 93 | UserId = 1 94 | }); 95 | }); 96 | 97 | modelBuilder.Entity("GroceryListApi.Database.User", b => 98 | { 99 | b.Property("Id") 100 | .ValueGeneratedOnAdd() 101 | .HasColumnType("INTEGER"); 102 | 103 | b.Property("Email") 104 | .IsRequired() 105 | .HasColumnType("TEXT"); 106 | 107 | b.Property("Name") 108 | .IsRequired() 109 | .HasColumnType("TEXT"); 110 | 111 | b.Property("Username") 112 | .IsRequired() 113 | .HasColumnType("TEXT"); 114 | 115 | b.HasKey("Id"); 116 | 117 | b.HasIndex(new[] { "Username" }, "IX_Username"); 118 | 119 | b.ToTable("Users"); 120 | 121 | b.HasData( 122 | new 123 | { 124 | Id = 1, 125 | Email = "test@example.org", 126 | Name = "Test user", 127 | Username = "test" 128 | }); 129 | }); 130 | 131 | modelBuilder.Entity("GroceryListApi.Database.GroceryListItem", b => 132 | { 133 | b.HasOne("GroceryListApi.Database.GroceryStore", "Store") 134 | .WithMany("Items") 135 | .HasForeignKey("StoreId") 136 | .OnDelete(DeleteBehavior.Cascade) 137 | .IsRequired(); 138 | 139 | b.HasOne("GroceryListApi.Database.User", "User") 140 | .WithMany("Items") 141 | .HasForeignKey("UserId") 142 | .OnDelete(DeleteBehavior.Cascade) 143 | .IsRequired(); 144 | 145 | b.Navigation("Store"); 146 | 147 | b.Navigation("User"); 148 | }); 149 | 150 | modelBuilder.Entity("GroceryListApi.Database.GroceryStore", b => 151 | { 152 | b.HasOne("GroceryListApi.Database.User", "User") 153 | .WithMany("Stores") 154 | .HasForeignKey("UserId") 155 | .OnDelete(DeleteBehavior.Cascade) 156 | .IsRequired(); 157 | 158 | b.Navigation("User"); 159 | }); 160 | 161 | modelBuilder.Entity("GroceryListApi.Database.GroceryStore", b => 162 | { 163 | b.Navigation("Items"); 164 | }); 165 | 166 | modelBuilder.Entity("GroceryListApi.Database.User", b => 167 | { 168 | b.Navigation("Items"); 169 | 170 | b.Navigation("Stores"); 171 | }); 172 | #pragma warning restore 612, 618 173 | } 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /GroceryListApi/Program.cs: -------------------------------------------------------------------------------- 1 | using GroceryListApi.Endpoints; 2 | using GroceryListApi.Startup; 3 | using Microsoft.EntityFrameworkCore; 4 | 5 | var builder = WebApplication.CreateBuilder(args); 6 | 7 | // Setup services 8 | builder.Services.AddSqlite("Data Source=grocerylists.db;Cache=Shared"); 9 | builder.Services.AddDatabaseDeveloperPageExceptionFilter(); 10 | 11 | builder.AddAuthenticationServices(); 12 | builder.AddSwaggerServices(); 13 | 14 | // Setup application 15 | var app = builder.Build(); 16 | 17 | await EnsureDb(app.Services, app.Logger); 18 | 19 | app.MapSwaggerEndpoints(); 20 | 21 | app.UseHttpsRedirection(); 22 | app.UseAuthentication(); 23 | app.UseAuthorization(); 24 | 25 | app.MapAuthenticationEndpoints(); 26 | app.MapGroceryStoreEndpoints(); 27 | app.MapGroceryListItemEndpoints(); 28 | 29 | app.Run(); 30 | 31 | async Task EnsureDb(IServiceProvider services, ILogger logger) 32 | { 33 | await using var db = services.CreateScope().ServiceProvider.GetRequiredService(); 34 | if (db.Database.IsRelational()) 35 | { 36 | logger.LogInformation("Updating database..."); 37 | await db.Database.MigrateAsync(); 38 | logger.LogInformation("Updated database"); 39 | } 40 | } 41 | 42 | public partial class Program { } -------------------------------------------------------------------------------- /GroceryListApi/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/launchsettings.json", 3 | "profiles": { 4 | "GroceryListApi": { 5 | "commandName": "Project", 6 | "dotnetRunMessages": true, 7 | "launchBrowser": true, 8 | "launchUrl": "swagger", 9 | "applicationUrl": "https://localhost:7243;http://localhost:5243", 10 | "environmentVariables": { 11 | "ASPNETCORE_ENVIRONMENT": "Development" 12 | } 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /GroceryListApi/Startup/AuthenticationExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Claims; 2 | using System.Text; 3 | using Microsoft.AspNetCore.Authentication.JwtBearer; 4 | using Microsoft.AspNetCore.Authorization; 5 | using Microsoft.EntityFrameworkCore; 6 | using Microsoft.IdentityModel.Tokens; 7 | 8 | namespace GroceryListApi.Startup; 9 | 10 | public static class AuthenticationExtensions 11 | { 12 | public static WebApplicationBuilder AddAuthenticationServices( 13 | this WebApplicationBuilder builder) 14 | { 15 | builder.Services.AddAuthorization(options => 16 | { 17 | options.DefaultPolicy = new AuthorizationPolicyBuilder() 18 | .RequireAuthenticatedUser() 19 | .RequireClaim(GroceryClaimTypes.UserId) 20 | .Build(); 21 | }); 22 | 23 | builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) 24 | .AddJwtBearer(options => 25 | { 26 | options.TokenValidationParameters = new TokenValidationParameters 27 | { 28 | ValidateActor = true, 29 | ValidateAudience = true, 30 | ValidateLifetime = true, 31 | ValidateIssuerSigningKey = true, 32 | ValidIssuer = builder.Configuration["Issuer"], 33 | ValidAudience = builder.Configuration["Audience"], 34 | IssuerSigningKey = 35 | new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["SigningKey"]!)) 36 | }; 37 | 38 | options.Events = new JwtBearerEvents 39 | { 40 | OnTokenValidated = async context => 41 | { 42 | if (context.Principal == null) return; 43 | 44 | var db = context.HttpContext.RequestServices.GetRequiredService(); 45 | 46 | var user = await db.Users 47 | .AsNoTracking() 48 | .FirstOrDefaultAsync(u => u.Username == context.Principal.GetClaimValue(ClaimTypes.NameIdentifier)); 49 | 50 | if (user == null) 51 | { 52 | context.Fail("No matching user was found"); 53 | return; 54 | } 55 | 56 | var appIdentity = new ClaimsIdentity(new [] 57 | { 58 | new Claim(GroceryClaimTypes.UserId, user.Id.ToString()) 59 | }); 60 | 61 | context.Principal.AddIdentity(appIdentity); 62 | } 63 | }; 64 | }); 65 | 66 | return builder; 67 | } 68 | } -------------------------------------------------------------------------------- /GroceryListApi/Startup/SwaggerExtensions.cs: -------------------------------------------------------------------------------- 1 | using GroceryListApi.Authentication; 2 | using Microsoft.OpenApi.Models; 3 | 4 | namespace GroceryListApi.Startup; 5 | 6 | public static class SwaggerExtensions 7 | { 8 | public static WebApplicationBuilder AddSwaggerServices( 9 | this WebApplicationBuilder builder) 10 | { 11 | builder.Services.AddEndpointsApiExplorer(); 12 | builder.Services.AddSwaggerGen(c => 13 | { 14 | c.SwaggerDoc("v1", new() { 15 | Title = "GroceryListAPI", 16 | Version = "v1", 17 | Contact = new OpenApiContact 18 | { 19 | Name = "Maarten Balliauw", 20 | Email = "maarten@maartenballiauw.be" 21 | } 22 | }); 23 | 24 | c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme 25 | { 26 | Description = "JWT Authorization header using the Bearer scheme. Example: \"Authorization: Bearer {token}\"", 27 | Name = "Authorization", 28 | In = ParameterLocation.Header, 29 | Type = SecuritySchemeType.Http, 30 | Scheme = "Bearer" 31 | }); 32 | 33 | c.AddSecurityRequirement(new OpenApiSecurityRequirement { 34 | { 35 | new OpenApiSecurityScheme 36 | { 37 | Reference = new OpenApiReference 38 | { 39 | Type = ReferenceType.SecurityScheme, 40 | Id = "Bearer" 41 | }, 42 | Scheme = "oauth2", 43 | Name = "Bearer", 44 | In = ParameterLocation.Header, 45 | 46 | }, 47 | new List() 48 | }}); 49 | 50 | c.OperationFilter(); 51 | }); 52 | 53 | return builder; 54 | } 55 | } -------------------------------------------------------------------------------- /GroceryListApi/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /GroceryListApi/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "AllowedHosts": "*", 9 | 10 | "Issuer" : "https://localhost:7243/", 11 | "Audience" : "https://localhost:7243/", 12 | "SigningKey" : "Groceries Signing Key Which Must Be Very Long And Should Not Be Guessable By Anyone Out There" 13 | } --------------------------------------------------------------------------------