Skip to content

Commit ae17e01

Browse files
author
Bart Koelman
authored
Various fixes and improvements (#1114)
* Fixed inconsistencies in naming of resource type * Fixed: Query to determine initial state is sent to the read-only database on POST many-to-many and DELETE to-many requests * Make command/query controllers inherit from JsonApiController, passing null for missing services This produces HTTP 405 (Method Not Allowed) instead of 404 (Not Found), which better reveals the intent * Added overload on TypeExtensions.IsOrImplementsInterface to take a type parameter, used for non-generic or generic constructed interfaces * Use ResourceType instead of public name in local-id tracker * Change IQueryStringParameterReader.AllowEmptyValue into normal interface member * Simplified startup in example project * Removed left-over overload with single type parameter in ResourceGraphBuilder * Various corrections in documentation, added #nullable where it makes a difference * Revert DocFx workaround * Updated version compatibility table * Added release notes and icon to NuGet package * Fix nullability warnings produced by .NET 6 with EF Core 6 * Fixed: Error when using EagerLoad on a relationship * Use VS2022 image in AppVeyor on Windows * Fixed broken tests on EF Core 6 Apparently EF Core 5 did not always fail on missing required relationships, which was fixed in EF Core 6. I tried to update existing tests leaving the models intact, but that poluted lots of tests so I made them optional instead. * Fixed redacted data when running tests * Breaking: Removed access-control action filter attributes such as HttpReadOnly, NoHttpPost etc. because they interfere with relationship endpoints. For example, blocking POST would block creating resources, as well as adding to to-many relationships, which is not very useful. The replacement is to inject just the subset of exposed services, or simply use the Command/Query controllers. When an endpoint is not exposed, we now return HTTP 403 Forbidden instead of 404 or 405. * Increase version number, use branch name in suffix * Pass the full resource to LinkBuilder, instead of just its ID. This allows for more intelligence in the link builder, such as handling versioning or inheritance. * Optimization: Only save when there are changes in a remove-from-to-many relationship request * Clarifications in doc-comments * Extract constant * Extract method * Cleanup SelectClauseBuilder.ToPropertySelectors * Since EF Core 5, SaveChanges automatically creates a savepoint and rolls back to it on failure. This produces more correct error responses in operations, compared to rolling back the entire transaction. For example, when an operations request creates a resource and the next operation fails, the resource service may incorrectly conclude that the resource from the first operation does not exist. * Improved unittests for populating IJsonApiRequest in middleware * Clarify intent in AtomicOperationObjectAdapter * Review feedback: use `ResourceType resourceType` instead of `ResourceType type` everywhere
1 parent 8a6bfac commit ae17e01

File tree

111 files changed

+1833
-1947
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

111 files changed

+1833
-1947
lines changed

Build.ps1

+2-2
Original file line numberDiff line numberDiff line change
@@ -73,10 +73,10 @@ function CreateNuGetPackage {
7373
$versionSuffix = $suffixSegments -join "-"
7474
}
7575
else {
76-
# Get the version suffix from the auto-incrementing build number. Example: "123" => "pre-0123".
76+
# Get the version suffix from the auto-incrementing build number. Example: "123" => "master-0123".
7777
if ($env:APPVEYOR_BUILD_NUMBER) {
7878
$revision = "{0:D4}" -f [convert]::ToInt32($env:APPVEYOR_BUILD_NUMBER, 10)
79-
$versionSuffix = "pre-$revision"
79+
$versionSuffix = "$($env:APPVEYOR_PULL_REQUEST_HEAD_REPO_BRANCH ?? $env:APPVEYOR_REPO_BRANCH)-$revision"
8080
}
8181
else {
8282
$versionSuffix = "pre-0001"

README.md

+18-13
Original file line numberDiff line numberDiff line change
@@ -43,21 +43,23 @@ See [our documentation](https://www.jsonapi.net/) for detailed usage.
4343
### Models
4444

4545
```c#
46-
public class Article : Identifiable
46+
#nullable enable
47+
48+
public class Article : Identifiable<int>
4749
{
4850
[Attr]
49-
public string Name { get; set; }
51+
public string Name { get; set; } = null!;
5052
}
5153
```
5254

5355
### Controllers
5456

5557
```c#
56-
public class ArticlesController : JsonApiController<Article>
58+
public class ArticlesController : JsonApiController<Article, int>
5759
{
58-
public ArticlesController(IJsonApiOptions options, ILoggerFactory loggerFactory,
59-
IResourceService<Article> resourceService,)
60-
: base(options, loggerFactory, resourceService)
60+
public ArticlesController(IJsonApiOptions options, IResourceGraph resourceGraph,
61+
ILoggerFactory loggerFactory, IResourceService<Article, int> resourceService)
62+
: base(options, resourceGraph, loggerFactory, resourceService)
6163
{
6264
}
6365
}
@@ -87,13 +89,16 @@ public class Startup
8789
The following chart should help you pick the best version, based on your environment.
8890
See also our [versioning policy](./VERSIONING_POLICY.md).
8991

90-
| .NET version | Entity Framework Core version | JsonApiDotNetCore version |
91-
| ------------ | ----------------------------- | ------------------------- |
92-
| Core 2.x | 2.x | 3.x |
93-
| Core 3.1 | 3.1 | 4.x |
94-
| Core 3.1 | 5 | 4.x |
95-
| 5 | 5 | 4.x or 5.x |
96-
| 6 | 6 | 5.x |
92+
| JsonApiDotNetCore | .NET | Entity Framework Core | Status |
93+
| ----------------- | -------- | --------------------- | -------------------------- |
94+
| 3.x | Core 2.x | 2.x | Released |
95+
| 4.x | Core 3.1 | 3.1 | Released |
96+
| | Core 3.1 | 5 | |
97+
| | 5 | 5 | |
98+
| | 6 | 5 | |
99+
| v5.x (pending) | 5 | 5 | On AppVeyor, to-be-dropped |
100+
| | 6 | 5 | On AppVeyor, to-be-dropped |
101+
| | 6 | 6 | Requires build from master |
97102

98103
## Contributing
99104

appveyor.yml

+3-5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
image:
22
- Ubuntu
3-
- Visual Studio 2019
3+
- Visual Studio 2022
44

55
version: '{build}'
66

@@ -32,7 +32,7 @@ for:
3232
-
3333
matrix:
3434
only:
35-
- image: Visual Studio 2019
35+
- image: Visual Studio 2022
3636
services:
3737
- postgresql13
3838
# REF: https://github.com/docascode/docfx-seed/blob/master/appveyor.yml
@@ -42,9 +42,7 @@ for:
4242
# https://dotnet.github.io/docfx/tutorial/docfx_getting_started.html
4343
git checkout $env:APPVEYOR_REPO_BRANCH -q
4444
}
45-
# Pinning to previous version, because zip of v2.58.8 (released 2d ago) is corrupt.
46-
# Tracked at https://github.com/dotnet/docfx/issues/7689
47-
choco install docfx -y --version 2.58.5
45+
choco install docfx -y
4846
if ($lastexitcode -ne 0) {
4947
throw "docfx install failed with exit code $lastexitcode."
5048
}

benchmarks/Deserialization/DeserializationBenchmarkBase.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ public abstract class DeserializationBenchmarkBase
2121
protected DeserializationBenchmarkBase()
2222
{
2323
var options = new JsonApiOptions();
24-
IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add<IncomingResource>().Build();
24+
IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add<IncomingResource, int>().Build();
2525
options.SerializerOptions.Converters.Add(new ResourceObjectConverter(resourceGraph));
2626
SerializerReadOptions = ((IJsonApiOptions)options).SerializerReadOptions;
2727

benchmarks/QueryString/QueryStringParserBenchmarks.cs

+2-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ public QueryStringParserBenchmarks()
2929
EnableLegacyFilterNotation = true
3030
};
3131

32-
IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add<QueryableResource>("alt-resource-name").Build();
32+
IResourceGraph resourceGraph =
33+
new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add<QueryableResource, int>("alt-resource-name").Build();
3334

3435
var request = new JsonApiRequest
3536
{

benchmarks/Serialization/SerializationBenchmarkBase.cs

+3-3
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ protected SerializationBenchmarkBase()
4040
}
4141
};
4242

43-
ResourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add<OutgoingResource>().Build();
43+
ResourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add<OutgoingResource, int>().Build();
4444
SerializerWriteOptions = ((IJsonApiOptions)options).SerializerWriteOptions;
4545

4646
// ReSharper disable VirtualMemberCallInConstructor
@@ -229,15 +229,15 @@ public TopLevelLinks GetTopLevelLinks()
229229
};
230230
}
231231

232-
public ResourceLinks GetResourceLinks(ResourceType resourceType, string id)
232+
public ResourceLinks GetResourceLinks(ResourceType resourceType, IIdentifiable resource)
233233
{
234234
return new ResourceLinks
235235
{
236236
Self = "Resource:Self"
237237
};
238238
}
239239

240-
public RelationshipLinks GetRelationshipLinks(RelationshipAttribute relationship, string leftId)
240+
public RelationshipLinks GetRelationshipLinks(RelationshipAttribute relationship, IIdentifiable leftResource)
241241
{
242242
return new RelationshipLinks
243243
{

docs/getting-started/step-by-step.md

+5-3
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,12 @@ Define your domain models such that they implement `IIdentifiable<TId>`.
3838
The easiest way to do this is to inherit from `Identifiable<TId>`.
3939

4040
```c#
41+
#nullable enable
42+
4143
public class Person : Identifiable<int>
4244
{
4345
[Attr]
44-
public string Name { get; set; }
46+
public string Name { get; set; } = null!;
4547
}
4648
```
4749

@@ -52,12 +54,12 @@ Nothing special here, just an ordinary `DbContext`.
5254
```
5355
public class AppDbContext : DbContext
5456
{
57+
public DbSet<Person> People => Set<Person>();
58+
5559
public AppDbContext(DbContextOptions<AppDbContext> options)
5660
: base(options)
5761
{
5862
}
59-
60-
public DbSet<Person> People { get; set; }
6163
}
6264
```
6365

docs/home/index.html

+20-16
Original file line numberDiff line numberDiff line change
@@ -142,31 +142,35 @@ <h2>Example usage</h2>
142142
<div class="icon"><i class='bx bx-detail'></i></div>
143143
<h4 class="title">Resource</h4>
144144
<pre>
145-
<code>public class Article : Identifiable
145+
<code>#nullable enable
146+
147+
public class Article : Identifiable&lt;long&gt;
146148
{
147149
[Attr]
148-
[Required, MaxLength(30)]
149-
public string Title { get; set; }
150+
[MaxLength(30)]
151+
public string Title { get; set; } = null!;
150152

151153
[Attr(Capabilities = AttrCapabilities.AllowFilter)]
152-
public string Summary { get; set; }
154+
public string? Summary { get; set; }
153155

154156
[Attr(PublicName = "websiteUrl")]
155-
public string Url { get; set; }
157+
public string? Url { get; set; }
158+
159+
[Attr]
160+
[Required]
161+
public int? WordCount { get; set; }
156162

157163
[Attr(Capabilities = AttrCapabilities.AllowView)]
158164
public DateTimeOffset LastModifiedAt { get; set; }
159165

160166
[HasOne]
161-
public Person Author { get; set; }
167+
public Person Author { get; set; } = null!;
162168

163-
[HasMany]
164-
public ICollection&lt;Revision&gt; Revisions { get; set; }
169+
[HasOne]
170+
public Person? Reviewer { get; set; }
165171

166-
[HasManyThrough(nameof(ArticleTags))]
167-
[NotMapped]
168-
public ICollection&lt;Tag&gt; Tags { get; set; }
169-
public ICollection&lt;ArticleTag&gt; ArticleTags { get; set; }
172+
[HasMany]
173+
public ICollection&lt;Tag&gt; Tags { get; set; } = new HashSet&lt;Tag&gt;();
170174
}</code>
171175
</pre>
172176
</div>
@@ -179,7 +183,7 @@ <h4 class="title">Resource</h4>
179183
<h4 class="title">Request</h4>
180184
<pre>
181185
<code>
182-
GET /articles?filter=contains(summary,'web')&sort=-lastModifiedAt&fields=title,summary&include=author HTTP/1.1
186+
GET /articles?filter=contains(summary,'web')&sort=-lastModifiedAt&fields[articles]=title,summary&include=author HTTP/1.1
183187
</code>
184188
</pre>
185189
</div>
@@ -197,9 +201,9 @@ <h4 class="title">Response</h4>
197201
"totalResources": 1
198202
},
199203
"links": {
200-
"self": "/articles?filter=contains(summary,'web')&sort=-lastModifiedAt&fields=title,summary&include=author",
201-
"first": "/articles?filter=contains(summary,'web')&sort=-lastModifiedAt&fields=title,summary&include=author",
202-
"last": "/articles?filter=contains(summary,'web')&sort=-lastModifiedAt&fields=title,summary&include=author"
204+
"self": "/articles?filter=contains(summary,'web')&sort=-lastModifiedAt&fields%5Barticles%5D=title,summary&include=author",
205+
"first": "/articles?filter=contains(summary,'web')&sort=-lastModifiedAt&fields%5Barticles%5D=title,summary&include=author",
206+
"last": "/articles?filter=contains(summary,'web')&sort=-lastModifiedAt&fields%5Barticles%5D=title,summary&include=author"
203207
},
204208
"data": [
205209
{

docs/usage/errors.md

+12-8
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ From a controller method:
1010
return Conflict(new Error(HttpStatusCode.Conflict)
1111
{
1212
Title = "Target resource was modified by another user.",
13-
Detail = $"User {userName} changed the {resourceField} field on the {resourceName} resource."
13+
Detail = $"User {userName} changed the {resourceField} field on {resourceName} resource."
1414
});
1515
```
1616

@@ -20,7 +20,7 @@ From other code:
2020
throw new JsonApiException(new Error(HttpStatusCode.Conflict)
2121
{
2222
Title = "Target resource was modified by another user.",
23-
Detail = $"User {userName} changed the {resourceField} field on the {resourceName} resource."
23+
Detail = $"User {userName} changed the {resourceField} field on {resourceName} resource."
2424
});
2525
```
2626

@@ -69,18 +69,22 @@ public class CustomExceptionHandler : ExceptionHandler
6969
return base.GetLogMessage(exception);
7070
}
7171

72-
protected override ErrorDocument CreateErrorDocument(Exception exception)
72+
protected override IReadOnlyList<ErrorObject> CreateErrorResponse(Exception exception)
7373
{
7474
if (exception is ProductOutOfStockException productOutOfStock)
7575
{
76-
return new ErrorDocument(new Error(HttpStatusCode.Conflict)
76+
return new[]
7777
{
78-
Title = "Product is temporarily available.",
79-
Detail = $"Product {productOutOfStock.ProductId} cannot be ordered at the moment."
80-
});
78+
new Error(HttpStatusCode.Conflict)
79+
{
80+
Title = "Product is temporarily available.",
81+
Detail = $"Product {productOutOfStock.ProductId} " +
82+
"cannot be ordered at the moment."
83+
}
84+
};
8185
}
8286

83-
return base.CreateErrorDocument(exception);
87+
return base.CreateErrorResponse(exception);
8488
}
8589
}
8690

docs/usage/extensibility/controllers.md

+25-59
Original file line numberDiff line numberDiff line change
@@ -13,83 +13,49 @@ public class ArticlesController : JsonApiController<Article, Guid>
1313
}
1414
```
1515

16+
If you want to setup routes yourself, you can instead inherit from `BaseJsonApiController<TResource, TId>` and override its methods with your own `[HttpGet]`, `[HttpHead]`, `[HttpPost]`, `[HttpPatch]` and `[HttpDelete]` attributes added on them. Don't forget to add `[FromBody]` on parameters where needed.
17+
1618
## Resource Access Control
1719

18-
It is often desirable to limit what methods are exposed on your controller. The first way you can do this, is to simply inherit from `BaseJsonApiController` and explicitly declare what methods are available.
20+
It is often desirable to limit which routes are exposed on your controller.
1921

20-
In this example, if a client attempts to do anything other than GET a resource, an HTTP 404 Not Found response will be returned since no other methods are exposed.
22+
To provide read-only access, inherit from `JsonApiQueryController` instead, which blocks all POST, PATCH and DELETE requests.
23+
Likewise, to provide write-only access, inherit from `JsonApiCommandController`, which blocks all GET and HEAD requests.
2124

22-
This approach is ok, but introduces some boilerplate that can easily be avoided.
25+
You can even make your own mix of allowed routes by calling the alternate constructor of `JsonApiController` and injecting the set of service implementations available.
26+
In some cases, resources may be an aggregation of entities or a view on top of the underlying entities. In these cases, there may not be a writable `IResourceService` implementation, so simply inject the implementation that is available.
2327

2428
```c#
25-
public class ArticlesController : BaseJsonApiController<Article, int>
29+
public class ReportsController : JsonApiController<Report, int>
2630
{
27-
public ArticlesController(IJsonApiOptions options, IResourceGraph resourceGraph,
28-
ILoggerFactory loggerFactory, IResourceService<Article, int> resourceService)
29-
: base(options, resourceGraph, loggerFactory, resourceService)
30-
{
31-
}
32-
33-
[HttpGet]
34-
public override async Task<IActionResult> GetAsync(CancellationToken cancellationToken)
35-
{
36-
return await base.GetAsync(cancellationToken);
37-
}
38-
39-
[HttpGet("{id}")]
40-
public override async Task<IActionResult> GetAsync(int id,
41-
CancellationToken cancellationToken)
31+
public ReportsController(IJsonApiOptions options, IResourceGraph resourceGraph,
32+
ILoggerFactory loggerFactory, IGetAllService<Report, int> getAllService)
33+
: base(options, resourceGraph, loggerFactory, getAll: getAllService)
4234
{
43-
return await base.GetAsync(id, cancellationToken);
4435
}
4536
}
4637
```
4738

48-
## Using ActionFilterAttributes
49-
50-
The next option is to use the ActionFilter attributes that ship with the library. The available attributes are:
51-
52-
- `NoHttpPost`: disallow POST requests
53-
- `NoHttpPatch`: disallow PATCH requests
54-
- `NoHttpDelete`: disallow DELETE requests
55-
- `HttpReadOnly`: all of the above
39+
For more information about resource service injection, see [Replacing injected services](~/usage/extensibility/layer-overview.md#replacing-injected-services) and [Resource Services](~/usage/extensibility/services.md).
5640

57-
Not only does this reduce boilerplate, but it also provides a more meaningful HTTP response code.
58-
An attempt to use one of the blacklisted methods will result in a HTTP 405 Method Not Allowed response.
41+
When a route is blocked, an HTTP 403 Forbidden response is returned.
5942

60-
```c#
61-
[HttpReadOnly]
62-
public class ArticlesController : BaseJsonApiController<Article, int>
63-
{
64-
public ArticlesController(IJsonApiOptions options, IResourceGraph resourceGraph,
65-
ILoggerFactory loggerFactory, IResourceService<Article, int> resourceService)
66-
: base(options, resourceGraph, loggerFactory, resourceService)
67-
{
68-
}
69-
}
43+
```http
44+
DELETE http://localhost:14140/people/1 HTTP/1.1
7045
```
7146

72-
## Implicit Access By Service Injection
73-
74-
Finally, you can control the allowed methods by supplying only the available service implementations. In some cases, resources may be an aggregation of entities or a view on top of the underlying entities. In these cases, there may not be a writable `IResourceService` implementation, so simply inject the implementation that is available.
75-
76-
As with the ActionFilter attributes, if a service implementation is not available to service a request, HTTP 405 Method Not Allowed will be returned.
77-
78-
For more information about resource service injection, see [Replacing injected services](~/usage/extensibility/layer-overview.md#replacing-injected-services) and [Resource Services](~/usage/extensibility/services.md).
79-
80-
```c#
81-
public class ReportsController : BaseJsonApiController<Report, int>
47+
```json
8248
{
83-
public ReportsController(IJsonApiOptions options, IResourceGraph resourceGraph,
84-
ILoggerFactory loggerFactory, IGetAllService<Report, int> getAllService)
85-
: base(options, resourceGraph, loggerFactory, getAllService)
86-
{
87-
}
88-
89-
[HttpGet]
90-
public override async Task<IActionResult> GetAsync(CancellationToken cancellationToken)
49+
"links": {
50+
"self": "/api/v1/people"
51+
},
52+
"errors": [
9153
{
92-
return await base.GetAsync(cancellationToken);
54+
"id": "dde7f219-2274-4473-97ef-baac3e7c1487",
55+
"status": "403",
56+
"title": "The requested endpoint is not accessible.",
57+
"detail": "Endpoint '/people/1' is not accessible for DELETE requests."
9358
}
59+
]
9460
}
9561
```

0 commit comments

Comments
 (0)