├── .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 | }
--------------------------------------------------------------------------------