diff --git a/Build.ps1 b/Build.ps1 index fa3c4e4f6c..b65fb5231a 100644 --- a/Build.ps1 +++ b/Build.ps1 @@ -73,10 +73,10 @@ function CreateNuGetPackage { $versionSuffix = $suffixSegments -join "-" } else { - # Get the version suffix from the auto-incrementing build number. Example: "123" => "pre-0123". + # Get the version suffix from the auto-incrementing build number. Example: "123" => "master-0123". if ($env:APPVEYOR_BUILD_NUMBER) { $revision = "{0:D4}" -f [convert]::ToInt32($env:APPVEYOR_BUILD_NUMBER, 10) - $versionSuffix = "pre-$revision" + $versionSuffix = "$($env:APPVEYOR_PULL_REQUEST_HEAD_REPO_BRANCH ?? $env:APPVEYOR_REPO_BRANCH)-$revision" } else { $versionSuffix = "pre-0001" diff --git a/README.md b/README.md index 8f6c6f3eb1..e9670ff393 100644 --- a/README.md +++ b/README.md @@ -43,21 +43,23 @@ See [our documentation](https://www.jsonapi.net/) for detailed usage. ### Models ```c# -public class Article : Identifiable +#nullable enable + +public class Article : Identifiable { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; } ``` ### Controllers ```c# -public class ArticlesController : JsonApiController
+public class ArticlesController : JsonApiController { - public ArticlesController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService
resourceService,) - : base(options, loggerFactory, resourceService) + public ArticlesController(IJsonApiOptions options, IResourceGraph resourceGraph, + ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } @@ -87,13 +89,16 @@ public class Startup The following chart should help you pick the best version, based on your environment. See also our [versioning policy](./VERSIONING_POLICY.md). -| .NET version | Entity Framework Core version | JsonApiDotNetCore version | -| ------------ | ----------------------------- | ------------------------- | -| Core 2.x | 2.x | 3.x | -| Core 3.1 | 3.1 | 4.x | -| Core 3.1 | 5 | 4.x | -| 5 | 5 | 4.x or 5.x | -| 6 | 6 | 5.x | +| JsonApiDotNetCore | .NET | Entity Framework Core | Status | +| ----------------- | -------- | --------------------- | -------------------------- | +| 3.x | Core 2.x | 2.x | Released | +| 4.x | Core 3.1 | 3.1 | Released | +| | Core 3.1 | 5 | | +| | 5 | 5 | | +| | 6 | 5 | | +| v5.x (pending) | 5 | 5 | On AppVeyor, to-be-dropped | +| | 6 | 5 | On AppVeyor, to-be-dropped | +| | 6 | 6 | Requires build from master | ## Contributing diff --git a/appveyor.yml b/appveyor.yml index 2b69ff39ca..4a39f6c4e3 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,6 +1,6 @@ image: - Ubuntu - - Visual Studio 2019 + - Visual Studio 2022 version: '{build}' @@ -32,7 +32,7 @@ for: - matrix: only: - - image: Visual Studio 2019 + - image: Visual Studio 2022 services: - postgresql13 # REF: https://github.com/docascode/docfx-seed/blob/master/appveyor.yml @@ -42,9 +42,7 @@ for: # https://dotnet.github.io/docfx/tutorial/docfx_getting_started.html git checkout $env:APPVEYOR_REPO_BRANCH -q } - # Pinning to previous version, because zip of v2.58.8 (released 2d ago) is corrupt. - # Tracked at https://github.com/dotnet/docfx/issues/7689 - choco install docfx -y --version 2.58.5 + choco install docfx -y if ($lastexitcode -ne 0) { throw "docfx install failed with exit code $lastexitcode." } diff --git a/benchmarks/Deserialization/DeserializationBenchmarkBase.cs b/benchmarks/Deserialization/DeserializationBenchmarkBase.cs index 8b2deb98b9..b21d7c85e7 100644 --- a/benchmarks/Deserialization/DeserializationBenchmarkBase.cs +++ b/benchmarks/Deserialization/DeserializationBenchmarkBase.cs @@ -21,7 +21,7 @@ public abstract class DeserializationBenchmarkBase protected DeserializationBenchmarkBase() { var options = new JsonApiOptions(); - IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add().Build(); + IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add().Build(); options.SerializerOptions.Converters.Add(new ResourceObjectConverter(resourceGraph)); SerializerReadOptions = ((IJsonApiOptions)options).SerializerReadOptions; diff --git a/benchmarks/QueryString/QueryStringParserBenchmarks.cs b/benchmarks/QueryString/QueryStringParserBenchmarks.cs index 6e576bc4a7..42d34f8ce4 100644 --- a/benchmarks/QueryString/QueryStringParserBenchmarks.cs +++ b/benchmarks/QueryString/QueryStringParserBenchmarks.cs @@ -29,7 +29,8 @@ public QueryStringParserBenchmarks() EnableLegacyFilterNotation = true }; - IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add("alt-resource-name").Build(); + IResourceGraph resourceGraph = + new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add("alt-resource-name").Build(); var request = new JsonApiRequest { diff --git a/benchmarks/Serialization/SerializationBenchmarkBase.cs b/benchmarks/Serialization/SerializationBenchmarkBase.cs index 2abde17e42..84d28c22ab 100644 --- a/benchmarks/Serialization/SerializationBenchmarkBase.cs +++ b/benchmarks/Serialization/SerializationBenchmarkBase.cs @@ -40,7 +40,7 @@ protected SerializationBenchmarkBase() } }; - ResourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add().Build(); + ResourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add().Build(); SerializerWriteOptions = ((IJsonApiOptions)options).SerializerWriteOptions; // ReSharper disable VirtualMemberCallInConstructor @@ -229,7 +229,7 @@ public TopLevelLinks GetTopLevelLinks() }; } - public ResourceLinks GetResourceLinks(ResourceType resourceType, string id) + public ResourceLinks GetResourceLinks(ResourceType resourceType, IIdentifiable resource) { return new ResourceLinks { @@ -237,7 +237,7 @@ public ResourceLinks GetResourceLinks(ResourceType resourceType, string id) }; } - public RelationshipLinks GetRelationshipLinks(RelationshipAttribute relationship, string leftId) + public RelationshipLinks GetRelationshipLinks(RelationshipAttribute relationship, IIdentifiable leftResource) { return new RelationshipLinks { diff --git a/docs/getting-started/step-by-step.md b/docs/getting-started/step-by-step.md index adc9bdf8e4..21daf04171 100644 --- a/docs/getting-started/step-by-step.md +++ b/docs/getting-started/step-by-step.md @@ -38,10 +38,12 @@ Define your domain models such that they implement `IIdentifiable`. The easiest way to do this is to inherit from `Identifiable`. ```c# +#nullable enable + public class Person : Identifiable { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; } ``` @@ -52,12 +54,12 @@ Nothing special here, just an ordinary `DbContext`. ``` public class AppDbContext : DbContext { + public DbSet People => Set(); + public AppDbContext(DbContextOptions options) : base(options) { } - - public DbSet People { get; set; } } ``` diff --git a/docs/home/index.html b/docs/home/index.html index 661819f3f6..7f01a30e32 100644 --- a/docs/home/index.html +++ b/docs/home/index.html @@ -142,31 +142,35 @@

Example usage

Resource

-public class Article : Identifiable
+#nullable enable
+
+public class Article : Identifiable<long>
 {
     [Attr]
-    [Required, MaxLength(30)]
-    public string Title { get; set; }
+    [MaxLength(30)]
+    public string Title { get; set; } = null!;
 
     [Attr(Capabilities = AttrCapabilities.AllowFilter)]
-    public string Summary { get; set; }
+    public string? Summary { get; set; }
 
     [Attr(PublicName = "websiteUrl")]
-    public string Url { get; set; }
+    public string? Url { get; set; }
+
+    [Attr]
+    [Required]
+    public int? WordCount { get; set; }
 
     [Attr(Capabilities = AttrCapabilities.AllowView)]
     public DateTimeOffset LastModifiedAt { get; set; }
 
     [HasOne]
-    public Person Author { get; set; }
+    public Person Author { get; set; } = null!;
 
-    [HasMany]
-    public ICollection<Revision> Revisions { get; set; }  
+    [HasOne]
+    public Person? Reviewer { get; set; }
 
-    [HasManyThrough(nameof(ArticleTags))]
-    [NotMapped]
-    public ICollection<Tag> Tags { get; set; }
-    public ICollection<ArticleTag> ArticleTags { get; set; }
+    [HasMany]
+    public ICollection<Tag> Tags { get; set; } = new HashSet<Tag>();
 }
                      
@@ -179,7 +183,7 @@

Resource

Request

 
-GET /articles?filter=contains(summary,'web')&sort=-lastModifiedAt&fields=title,summary&include=author HTTP/1.1
+GET /articles?filter=contains(summary,'web')&sort=-lastModifiedAt&fields[articles]=title,summary&include=author HTTP/1.1
 
                      
@@ -197,9 +201,9 @@

Response

"totalResources": 1 }, "links": { - "self": "/articles?filter=contains(summary,'web')&sort=-lastModifiedAt&fields=title,summary&include=author", - "first": "/articles?filter=contains(summary,'web')&sort=-lastModifiedAt&fields=title,summary&include=author", - "last": "/articles?filter=contains(summary,'web')&sort=-lastModifiedAt&fields=title,summary&include=author" + "self": "/articles?filter=contains(summary,'web')&sort=-lastModifiedAt&fields%5Barticles%5D=title,summary&include=author", + "first": "/articles?filter=contains(summary,'web')&sort=-lastModifiedAt&fields%5Barticles%5D=title,summary&include=author", + "last": "/articles?filter=contains(summary,'web')&sort=-lastModifiedAt&fields%5Barticles%5D=title,summary&include=author" }, "data": [ { diff --git a/docs/usage/errors.md b/docs/usage/errors.md index 96722739b4..3278526e6c 100644 --- a/docs/usage/errors.md +++ b/docs/usage/errors.md @@ -10,7 +10,7 @@ From a controller method: return Conflict(new Error(HttpStatusCode.Conflict) { Title = "Target resource was modified by another user.", - Detail = $"User {userName} changed the {resourceField} field on the {resourceName} resource." + Detail = $"User {userName} changed the {resourceField} field on {resourceName} resource." }); ``` @@ -20,7 +20,7 @@ From other code: throw new JsonApiException(new Error(HttpStatusCode.Conflict) { Title = "Target resource was modified by another user.", - Detail = $"User {userName} changed the {resourceField} field on the {resourceName} resource." + Detail = $"User {userName} changed the {resourceField} field on {resourceName} resource." }); ``` @@ -69,18 +69,22 @@ public class CustomExceptionHandler : ExceptionHandler return base.GetLogMessage(exception); } - protected override ErrorDocument CreateErrorDocument(Exception exception) + protected override IReadOnlyList CreateErrorResponse(Exception exception) { if (exception is ProductOutOfStockException productOutOfStock) { - return new ErrorDocument(new Error(HttpStatusCode.Conflict) + return new[] { - Title = "Product is temporarily available.", - Detail = $"Product {productOutOfStock.ProductId} cannot be ordered at the moment." - }); + new Error(HttpStatusCode.Conflict) + { + Title = "Product is temporarily available.", + Detail = $"Product {productOutOfStock.ProductId} " + + "cannot be ordered at the moment." + } + }; } - return base.CreateErrorDocument(exception); + return base.CreateErrorResponse(exception); } } diff --git a/docs/usage/extensibility/controllers.md b/docs/usage/extensibility/controllers.md index 9fc9d380f1..1993f77841 100644 --- a/docs/usage/extensibility/controllers.md +++ b/docs/usage/extensibility/controllers.md @@ -13,83 +13,49 @@ public class ArticlesController : JsonApiController } ``` +If you want to setup routes yourself, you can instead inherit from `BaseJsonApiController` 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. + ## Resource Access Control -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. +It is often desirable to limit which routes are exposed on your controller. -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. +To provide read-only access, inherit from `JsonApiQueryController` instead, which blocks all POST, PATCH and DELETE requests. +Likewise, to provide write-only access, inherit from `JsonApiCommandController`, which blocks all GET and HEAD requests. -This approach is ok, but introduces some boilerplate that can easily be avoided. +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. +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. ```c# -public class ArticlesController : BaseJsonApiController +public class ReportsController : JsonApiController { - public ArticlesController(IJsonApiOptions options, IResourceGraph resourceGraph, - ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, resourceGraph, loggerFactory, resourceService) - { - } - - [HttpGet] - public override async Task GetAsync(CancellationToken cancellationToken) - { - return await base.GetAsync(cancellationToken); - } - - [HttpGet("{id}")] - public override async Task GetAsync(int id, - CancellationToken cancellationToken) + public ReportsController(IJsonApiOptions options, IResourceGraph resourceGraph, + ILoggerFactory loggerFactory, IGetAllService getAllService) + : base(options, resourceGraph, loggerFactory, getAll: getAllService) { - return await base.GetAsync(id, cancellationToken); } } ``` -## Using ActionFilterAttributes - -The next option is to use the ActionFilter attributes that ship with the library. The available attributes are: - -- `NoHttpPost`: disallow POST requests -- `NoHttpPatch`: disallow PATCH requests -- `NoHttpDelete`: disallow DELETE requests -- `HttpReadOnly`: all of the above +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). -Not only does this reduce boilerplate, but it also provides a more meaningful HTTP response code. -An attempt to use one of the blacklisted methods will result in a HTTP 405 Method Not Allowed response. +When a route is blocked, an HTTP 403 Forbidden response is returned. -```c# -[HttpReadOnly] -public class ArticlesController : BaseJsonApiController -{ - public ArticlesController(IJsonApiOptions options, IResourceGraph resourceGraph, - ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, resourceGraph, loggerFactory, resourceService) - { - } -} +```http +DELETE http://localhost:14140/people/1 HTTP/1.1 ``` -## Implicit Access By Service Injection - -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. - -As with the ActionFilter attributes, if a service implementation is not available to service a request, HTTP 405 Method Not Allowed will be returned. - -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). - -```c# -public class ReportsController : BaseJsonApiController +```json { - public ReportsController(IJsonApiOptions options, IResourceGraph resourceGraph, - ILoggerFactory loggerFactory, IGetAllService getAllService) - : base(options, resourceGraph, loggerFactory, getAllService) - { - } - - [HttpGet] - public override async Task GetAsync(CancellationToken cancellationToken) + "links": { + "self": "/api/v1/people" + }, + "errors": [ { - return await base.GetAsync(cancellationToken); + "id": "dde7f219-2274-4473-97ef-baac3e7c1487", + "status": "403", + "title": "The requested endpoint is not accessible.", + "detail": "Endpoint '/people/1' is not accessible for DELETE requests." } + ] } ``` diff --git a/docs/usage/extensibility/services.md b/docs/usage/extensibility/services.md index 1842a44606..77d772435e 100644 --- a/docs/usage/extensibility/services.md +++ b/docs/usage/extensibility/services.md @@ -19,7 +19,8 @@ public class TodoItemService : JsonApiResourceService IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, IJsonApiOptions options, ILoggerFactory loggerFactory, IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, - IResourceDefinitionAccessor resourceDefinitionAccessor) + IResourceDefinitionAccessor resourceDefinitionAccessor, + INotificationService notificationService) : base(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, resourceDefinitionAccessor) { @@ -121,7 +122,7 @@ IResourceService In order to take advantage of these interfaces you first need to register the service for each implemented interface. ```c# -public class ArticleService : ICreateService
, IDeleteService
+public class ArticleService : ICreateService, IDeleteService { // ... } @@ -130,8 +131,8 @@ public class Startup { public void ConfigureServices(IServiceCollection services) { - services.AddScoped, ArticleService>(); - services.AddScoped, ArticleService>(); + services.AddScoped, ArticleService>(); + services.AddScoped, ArticleService>(); } } ``` @@ -151,10 +152,10 @@ public class Startup } ``` -Then in the controller, you should inherit from the base controller and pass the services into the named, optional base parameters: +Then in the controller, you should inherit from the JSON:API controller and pass the services into the named, optional base parameters: ```c# -public class ArticlesController : BaseJsonApiController +public class ArticlesController : JsonApiController { public ArticlesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, ICreateService create, @@ -162,19 +163,5 @@ public class ArticlesController : BaseJsonApiController : base(options, resourceGraph, loggerFactory, create: create, delete: delete) { } - - [HttpPost] - public override async Task PostAsync([FromBody] Article resource, - CancellationToken cancellationToken) - { - return await base.PostAsync(resource, cancellationToken); - } - - [HttpDelete("{id}")] - public override async TaskDeleteAsync(int id, - CancellationToken cancellationToken) - { - return await base.DeleteAsync(id, cancellationToken); - } } ``` diff --git a/docs/usage/meta.md b/docs/usage/meta.md index 62eefaa158..29c074b8b6 100644 --- a/docs/usage/meta.md +++ b/docs/usage/meta.md @@ -8,14 +8,16 @@ Global metadata can be added to the root of the response document by registering This is useful if you need access to other registered services to build the meta object. ```c# +#nullable enable + // In Startup.ConfigureServices services.AddSingleton(); public sealed class CopyrightResponseMeta : IResponseMeta { - public IReadOnlyDictionary GetMeta() + public IReadOnlyDictionary GetMeta() { - return new Dictionary + return new Dictionary { ["copyright"] = "Copyright (C) 2002 Umbrella Corporation.", ["authors"] = new[] { "Alice", "Red Queen" } @@ -42,6 +44,8 @@ public sealed class CopyrightResponseMeta : IResponseMeta Resource-specific metadata can be added by implementing `IResourceDefinition.GetMeta` (or overriding it on `JsonApiResourceDefinition`): ```c# +#nullable enable + public class PersonDefinition : JsonApiResourceDefinition { public PersonDefinition(IResourceGraph resourceGraph) @@ -49,14 +53,14 @@ public class PersonDefinition : JsonApiResourceDefinition { } - public override IReadOnlyDictionary GetMeta(Person person) + public override IReadOnlyDictionary? GetMeta(Person person) { if (person.IsEmployee) { - return new Dictionary + return new Dictionary { ["notice"] = "Check our intranet at http://www.example.com/employees/" + - person.StringId + " for personal details." + $"{person.StringId} for personal details." }; } diff --git a/docs/usage/options.md b/docs/usage/options.md index 0cad33d655..2f350b8bf9 100644 --- a/docs/usage/options.md +++ b/docs/usage/options.md @@ -115,14 +115,19 @@ options.ValidateModelState = true; ``` ```c# +#nullable enable + public class Person : Identifiable { [Attr] - [Required] [MinLength(3)] - public string FirstName { get; set; } + public string FirstName { get; set; } = null!; + [Attr] [Required] - public LoginAccount Account : get; set; } + public int? Age { get; set; } + + [HasOne] + public LoginAccount Account { get; set; } = null!; } ``` diff --git a/docs/usage/resource-graph.md b/docs/usage/resource-graph.md index 11d13be951..beb20d2d92 100644 --- a/docs/usage/resource-graph.md +++ b/docs/usage/resource-graph.md @@ -65,7 +65,7 @@ public void ConfigureServices(IServiceCollection services) { services.AddJsonApi(resources: builder => { - builder.Add(); + builder.Add(); }); } ``` @@ -78,7 +78,7 @@ The public resource name is exposed through the `type` member in the JSON:API pa ```c# services.AddJsonApi(resources: builder => { - builder.Add(publicName: "people"); + builder.Add(publicName: "people"); }); ``` diff --git a/docs/usage/resources/attributes.md b/docs/usage/resources/attributes.md index 90fbb9d7c1..669dba0892 100644 --- a/docs/usage/resources/attributes.md +++ b/docs/usage/resources/attributes.md @@ -3,10 +3,15 @@ If you want an attribute on your model to be publicly available, add the `AttrAttribute`. ```c# +#nullable enable + public class Person : Identifiable { [Attr] - public string FirstName { get; set; } + public string? FirstName { get; set; } + + [Attr] + public string LastName { get; set; } = null!; } ``` @@ -18,10 +23,11 @@ There are two ways the exposed attribute name is determined: 2. Individually using the attribute's constructor. ```c# +#nullable enable public class Person : Identifiable { [Attr(PublicName = "first-name")] - public string FirstName { get; set; } + public string? FirstName { get; set; } } ``` @@ -42,10 +48,12 @@ This can be overridden per attribute. Attributes can be marked to allow returning their value in responses. When not allowed and requested using `?fields[]=`, it results in an HTTP 400 response. ```c# +#nullable enable + public class User : Identifiable { [Attr(Capabilities = ~AttrCapabilities.AllowView)] - public string Password { get; set; } + public string Password { get; set; } = null!; } ``` @@ -54,10 +62,12 @@ public class User : Identifiable Attributes can be marked as creatable, which will allow `POST` requests to assign a value to them. When sent but not allowed, an HTTP 422 response is returned. ```c# +#nullable enable + public class Person : Identifiable { [Attr(Capabilities = AttrCapabilities.AllowCreate)] - public string CreatorName { get; set; } + public string? CreatorName { get; set; } } ``` @@ -66,10 +76,12 @@ public class Person : Identifiable Attributes can be marked as changeable, which will allow `PATCH` requests to update them. When sent but not allowed, an HTTP 422 response is returned. ```c# +#nullable enable + public class Person : Identifiable { [Attr(Capabilities = AttrCapabilities.AllowChange)] - public string FirstName { get; set; } + public string? FirstName { get; set; }; } ``` @@ -78,10 +90,12 @@ public class Person : Identifiable Attributes can be marked to allow filtering and/or sorting. When not allowed, it results in an HTTP 400 response. ```c# +#nullable enable + public class Person : Identifiable { [Attr(Capabilities = AttrCapabilities.AllowSort | AttrCapabilities.AllowFilter)] - public string FirstName { get; set; } + public string? FirstName { get; set; } } ``` @@ -93,17 +107,19 @@ so you should use their APIs to specify serialization format. You can also use [global options](~/usage/options.md#customize-serializer-options) to control the `JsonSerializer` behavior. ```c# +#nullable enable + public class Foo : Identifiable { [Attr] - public Bar Bar { get; set; } + public Bar? Bar { get; set; } } public class Bar { [JsonPropertyName("compound-member")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string CompoundMember { get; set; } + public string? CompoundMember { get; set; } } ``` @@ -113,12 +129,15 @@ The first member is the concrete type that you will directly interact with in yo and retrieval. ```c# +#nullable enable + public class Foo : Identifiable { - [Attr, NotMapped] - public Bar Bar { get; set; } + [Attr] + [NotMapped] + public Bar? Bar { get; set; } - public string BarJson + public string? BarJson { get { diff --git a/docs/usage/resources/nullability.md b/docs/usage/resources/nullability.md index aaa5e10817..24b15572fc 100644 --- a/docs/usage/resources/nullability.md +++ b/docs/usage/resources/nullability.md @@ -33,6 +33,8 @@ When NRT is turned off, use `[Required]` on required attributes and relationship Example: ```c# +#nullable disable + public sealed class Label : Identifiable { [Attr] @@ -65,6 +67,8 @@ When ModelState validation is turned on, to-many relationships must be assigned Example: ```c# +#nullable enable + public sealed class Label : Identifiable { [Attr] diff --git a/docs/usage/resources/relationships.md b/docs/usage/resources/relationships.md index 662e8a1a3d..8776041e98 100644 --- a/docs/usage/resources/relationships.md +++ b/docs/usage/resources/relationships.md @@ -11,10 +11,12 @@ The left side of a relationship is where the relationship is declared, the right This exposes a to-one relationship. ```c# +#nullable enable + public class TodoItem : Identifiable { [HasOne] - public Person Owner { get; set; } + public Person? Owner { get; set; } } ``` @@ -28,16 +30,18 @@ This means no foreign key column is generated, instead the primary keys point to The next example defines that each car requires an engine, while an engine is optionally linked to a car. ```c# +#nullable enable + public sealed class Car : Identifiable { [HasOne] - public Engine Engine { get; set; } + public Engine Engine { get; set; } = null!; } public sealed class Engine : Identifiable { [HasOne] - public Car Car { get; set; } + public Car? Car { get; set; } } public sealed class AppDbContext : DbContext @@ -106,7 +110,7 @@ This exposes a to-many relationship. public class Person : Identifiable { [HasMany] - public ICollection TodoItems { get; set; } + public ICollection TodoItems { get; set; } = new HashSet(); } ``` @@ -122,6 +126,8 @@ which would expose the relationship to the client the same way as any other `Has However, under the covers it would use the join type and Entity Framework Core's APIs to get and set the relationship. ```c# +#nullable disable + public class Article : Identifiable { // tells Entity Framework Core to ignore this property @@ -146,10 +152,11 @@ There are two ways the exposed relationship name is determined: 2. Individually using the attribute's constructor. ```c# +#nullable enable public class TodoItem : Identifiable { [HasOne(PublicName = "item-owner")] - public Person Owner { get; set; } + public Person Owner { get; set; } = null!; } ``` @@ -158,10 +165,12 @@ public class TodoItem : Identifiable Relationships can be marked to disallow including them using the `?include=` query string parameter. When not allowed, it results in an HTTP 400 response. ```c# +#nullable enable + public class TodoItem : Identifiable { [HasOne(CanInclude: false)] - public Person Owner { get; set; } + public Person? Owner { get; set; } } ``` @@ -173,25 +182,24 @@ Your resource may expose a calculated property, whose value depends on a related So for the calculated property to be evaluated correctly, the related entity must always be retrieved. You can achieve that using `EagerLoad`, for example: ```c# +#nullable enable + public class ShippingAddress : Identifiable { [Attr] - public string Street { get; set; } + public string Street { get; set; } = null!; [Attr] - public string CountryName - { - get { return Country.DisplayName; } - } + public string? CountryName => Country?.DisplayName; // not exposed as resource, but adds .Include("Country") to the query [EagerLoad] - public Country Country { get; set; } + public Country? Country { get; set; } } public class Country { - public string IsoCode { get; set; } - public string DisplayName { get; set; } + public string IsoCode { get; set; } = null!; + public string DisplayName { get; set; } = null!; } ``` diff --git a/src/Examples/JsonApiDotNetCoreExample/Startup.cs b/src/Examples/JsonApiDotNetCoreExample/Startup.cs index 84ebc30360..9e0b608f85 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Startup.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Startup.cs @@ -62,17 +62,13 @@ public void ConfigureServices(IServiceCollection services) } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. - public void Configure(IApplicationBuilder app, IWebHostEnvironment environment, ILoggerFactory loggerFactory) + public void Configure(IApplicationBuilder app, IWebHostEnvironment environment, ILoggerFactory loggerFactory, AppDbContext dbContext) { ILogger logger = loggerFactory.CreateLogger(); using (CodeTimingSessionManager.Current.Measure("Initialize other (startup)")) { - using (IServiceScope scope = app.ApplicationServices.CreateScope()) - { - var appDbContext = scope.ServiceProvider.GetRequiredService(); - appDbContext.Database.EnsureCreated(); - } + dbContext.Database.EnsureCreated(); app.UseRouting(); diff --git a/src/Examples/NoEntityFrameworkExample/Startup.cs b/src/Examples/NoEntityFrameworkExample/Startup.cs index 14642d408f..dc86192832 100644 --- a/src/Examples/NoEntityFrameworkExample/Startup.cs +++ b/src/Examples/NoEntityFrameworkExample/Startup.cs @@ -24,7 +24,7 @@ public Startup(IConfiguration configuration) // This method gets called by the runtime. Use this method to add services to the container. public void ConfigureServices(IServiceCollection services) { - services.AddJsonApi(options => options.Namespace = "api/v1", resources: builder => builder.Add("workItems")); + services.AddJsonApi(options => options.Namespace = "api/v1", resources: builder => builder.Add("workItems")); services.AddResourceService(); diff --git a/src/JsonApiDotNetCore/AtomicOperations/ILocalIdTracker.cs b/src/JsonApiDotNetCore/AtomicOperations/ILocalIdTracker.cs index eb61e41371..1f6eda010d 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/ILocalIdTracker.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/ILocalIdTracker.cs @@ -1,3 +1,5 @@ +using JsonApiDotNetCore.Configuration; + namespace JsonApiDotNetCore.AtomicOperations { /// @@ -13,16 +15,16 @@ public interface ILocalIdTracker /// /// Declares a local ID without assigning a server-generated value. /// - void Declare(string localId, string resourceType); + void Declare(string localId, ResourceType resourceType); /// /// Assigns a server-generated ID value to a previously declared local ID. /// - void Assign(string localId, string resourceType, string stringId); + void Assign(string localId, ResourceType resourceType, string stringId); /// /// Gets the server-assigned ID for the specified local ID. /// - string GetValue(string localId, string resourceType); + string GetValue(string localId, ResourceType resourceType); } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs b/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs index 408553529e..9b24ff4e18 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; namespace JsonApiDotNetCore.AtomicOperations @@ -16,10 +17,10 @@ public void Reset() } /// - public void Declare(string localId, string resourceType) + public void Declare(string localId, ResourceType resourceType) { ArgumentGuard.NotNullNorEmpty(localId, nameof(localId)); - ArgumentGuard.NotNullNorEmpty(resourceType, nameof(resourceType)); + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); AssertIsNotDeclared(localId); @@ -35,10 +36,10 @@ private void AssertIsNotDeclared(string localId) } /// - public void Assign(string localId, string resourceType, string stringId) + public void Assign(string localId, ResourceType resourceType, string stringId) { ArgumentGuard.NotNullNorEmpty(localId, nameof(localId)); - ArgumentGuard.NotNullNorEmpty(resourceType, nameof(resourceType)); + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); ArgumentGuard.NotNullNorEmpty(stringId, nameof(stringId)); AssertIsDeclared(localId); @@ -56,10 +57,10 @@ public void Assign(string localId, string resourceType, string stringId) } /// - public string GetValue(string localId, string resourceType) + public string GetValue(string localId, ResourceType resourceType) { ArgumentGuard.NotNullNorEmpty(localId, nameof(localId)); - ArgumentGuard.NotNullNorEmpty(resourceType, nameof(resourceType)); + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); AssertIsDeclared(localId); @@ -83,20 +84,20 @@ private void AssertIsDeclared(string localId) } } - private static void AssertSameResourceType(string currentType, string declaredType, string localId) + private static void AssertSameResourceType(ResourceType currentType, ResourceType declaredType, string localId) { - if (declaredType != currentType) + if (!declaredType.Equals(currentType)) { - throw new IncompatibleLocalIdTypeException(localId, declaredType, currentType); + throw new IncompatibleLocalIdTypeException(localId, declaredType.PublicName, currentType.PublicName); } } private sealed class LocalIdState { - public string ResourceType { get; } + public ResourceType ResourceType { get; } public string? ServerId { get; set; } - public LocalIdState(string resourceType) + public LocalIdState(ResourceType resourceType) { ResourceType = resourceType; } diff --git a/src/JsonApiDotNetCore/AtomicOperations/LocalIdValidator.cs b/src/JsonApiDotNetCore/AtomicOperations/LocalIdValidator.cs index f08c1bc44c..670280e59b 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/LocalIdValidator.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/LocalIdValidator.cs @@ -81,7 +81,7 @@ private void DeclareLocalId(IIdentifiable resource, ResourceType resourceType) { if (resource.LocalId != null) { - _localIdTracker.Declare(resource.LocalId, resourceType.PublicName); + _localIdTracker.Declare(resource.LocalId, resourceType); } } @@ -89,7 +89,7 @@ private void AssignLocalId(OperationContainer operation, ResourceType resourceTy { if (operation.Resource.LocalId != null) { - _localIdTracker.Assign(operation.Resource.LocalId, resourceType.PublicName, "placeholder"); + _localIdTracker.Assign(operation.Resource.LocalId, resourceType, "placeholder"); } } @@ -98,7 +98,7 @@ private void AssertLocalIdIsAssigned(IIdentifiable resource) if (resource.LocalId != null) { ResourceType resourceType = _resourceGraph.GetResourceType(resource.GetType()); - _localIdTracker.GetValue(resource.LocalId, resourceType.PublicName); + _localIdTracker.GetValue(resource.LocalId, resourceType); } } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs index b9cbe983e0..ceca03ccdf 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs @@ -136,7 +136,7 @@ private void DeclareLocalId(IIdentifiable resource, ResourceType resourceType) { if (resource.LocalId != null) { - _localIdTracker.Declare(resource.LocalId, resourceType.PublicName); + _localIdTracker.Declare(resource.LocalId, resourceType); } } @@ -145,7 +145,7 @@ private void AssignStringId(IIdentifiable resource) if (resource.LocalId != null) { ResourceType resourceType = _resourceGraph.GetResourceType(resource.GetType()); - resource.StringId = _localIdTracker.GetValue(resource.LocalId, resourceType.PublicName); + resource.StringId = _localIdTracker.GetValue(resource.LocalId, resourceType); } } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs index 36cb8c573f..06d9ae485a 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs @@ -1,7 +1,6 @@ using System.Threading; using System.Threading.Tasks; using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Services; @@ -34,9 +33,7 @@ public CreateProcessor(ICreateService service, ILocalIdTracker l if (operation.Resource.LocalId != null) { string serverId = newResource != null ? newResource.StringId! : operation.Resource.StringId!; - ResourceType resourceType = operation.Request.PrimaryResourceType!; - - _localIdTracker.Assign(operation.Resource.LocalId, resourceType.PublicName, serverId); + _localIdTracker.Assign(operation.Resource.LocalId, operation.Request.PrimaryResourceType!, serverId); } return newResource == null ? null : operation.WithResource(newResource); diff --git a/src/JsonApiDotNetCore/CollectionConverter.cs b/src/JsonApiDotNetCore/CollectionConverter.cs index 5c5dcba845..1ab1768cb9 100644 --- a/src/JsonApiDotNetCore/CollectionConverter.cs +++ b/src/JsonApiDotNetCore/CollectionConverter.cs @@ -98,7 +98,7 @@ public ICollection ExtractResources(object? value) { if (type.IsGenericType && type.GenericTypeArguments.Length == 1) { - if (type.IsOrImplementsInterface(typeof(IEnumerable))) + if (type.IsOrImplementsInterface()) { return type.GenericTypeArguments[0]; } diff --git a/src/JsonApiDotNetCore/Configuration/InverseNavigationResolver.cs b/src/JsonApiDotNetCore/Configuration/InverseNavigationResolver.cs index 3814422da6..58d0919984 100644 --- a/src/JsonApiDotNetCore/Configuration/InverseNavigationResolver.cs +++ b/src/JsonApiDotNetCore/Configuration/InverseNavigationResolver.cs @@ -38,7 +38,7 @@ private void Resolve(DbContext dbContext) { foreach (ResourceType resourceType in _resourceGraph.GetResourceTypes().Where(resourceType => resourceType.Relationships.Any())) { - IEntityType entityType = dbContext.Model.FindEntityType(resourceType.ClrType); + IEntityType? entityType = dbContext.Model.FindEntityType(resourceType.ClrType); if (entityType != null) { diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs index 7d545a8490..53e206cc0f 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs @@ -86,22 +86,6 @@ private static bool IsImplicitManyToManyJoinEntity(IEntityType entityType) #pragma warning restore EF1001 // Internal Entity Framework Core API usage. } - /// - /// Adds a JSON:API resource with int as the identifier CLR type. - /// - /// - /// The resource CLR type. - /// - /// - /// The name under which the resource is publicly exposed by the API. If nothing is specified, the naming convention is applied on the pluralized CLR - /// type name. - /// - public ResourceGraphBuilder Add(string? publicName = null) - where TResource : class, IIdentifiable - { - return Add(publicName); - } - /// /// Adds a JSON:API resource. /// @@ -143,7 +127,7 @@ public ResourceGraphBuilder Add(Type resourceClrType, Type? idClrType = null, st return this; } - if (resourceClrType.IsOrImplementsInterface(typeof(IIdentifiable))) + if (resourceClrType.IsOrImplementsInterface()) { string effectivePublicName = publicName ?? FormatResourceName(resourceClrType); Type? effectiveIdType = idClrType ?? _typeLocator.LookupIdType(resourceClrType); diff --git a/src/JsonApiDotNetCore/Configuration/TypeLocator.cs b/src/JsonApiDotNetCore/Configuration/TypeLocator.cs index 6282ef05f4..9ef4f3590e 100644 --- a/src/JsonApiDotNetCore/Configuration/TypeLocator.cs +++ b/src/JsonApiDotNetCore/Configuration/TypeLocator.cs @@ -27,7 +27,7 @@ internal sealed class TypeLocator /// public ResourceDescriptor? ResolveResourceDescriptor(Type? type) { - if (type != null && type.IsOrImplementsInterface(typeof(IIdentifiable))) + if (type != null && type.IsOrImplementsInterface()) { Type? idType = LookupIdType(type); diff --git a/src/JsonApiDotNetCore/Controllers/Annotations/HttpReadOnlyAttribute.cs b/src/JsonApiDotNetCore/Controllers/Annotations/HttpReadOnlyAttribute.cs deleted file mode 100644 index 87bdc7c97c..0000000000 --- a/src/JsonApiDotNetCore/Controllers/Annotations/HttpReadOnlyAttribute.cs +++ /dev/null @@ -1,24 +0,0 @@ -using JetBrains.Annotations; - -namespace JsonApiDotNetCore.Controllers.Annotations -{ - /// - /// Used on an ASP.NET controller class to indicate write actions must be blocked. - /// - /// - /// { - /// } - /// ]]> - [PublicAPI] - public sealed class HttpReadOnlyAttribute : HttpRestrictAttribute - { - protected override string[] Methods { get; } = - { - "POST", - "PATCH", - "DELETE" - }; - } -} diff --git a/src/JsonApiDotNetCore/Controllers/Annotations/HttpRestrictAttribute.cs b/src/JsonApiDotNetCore/Controllers/Annotations/HttpRestrictAttribute.cs deleted file mode 100644 index c2534471f9..0000000000 --- a/src/JsonApiDotNetCore/Controllers/Annotations/HttpRestrictAttribute.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Linq; -using System.Net.Http; -using System.Threading.Tasks; -using JsonApiDotNetCore.Errors; -using Microsoft.AspNetCore.Mvc.Filters; - -namespace JsonApiDotNetCore.Controllers.Annotations -{ - public abstract class HttpRestrictAttribute : ActionFilterAttribute - { - protected abstract string[] Methods { get; } - - public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) - { - ArgumentGuard.NotNull(context, nameof(context)); - ArgumentGuard.NotNull(next, nameof(next)); - - string method = context.HttpContext.Request.Method; - - if (!CanExecuteAction(method)) - { - throw new RequestMethodNotAllowedException(new HttpMethod(method)); - } - - await next(); - } - - private bool CanExecuteAction(string requestMethod) - { - return !Methods.Contains(requestMethod); - } - } -} diff --git a/src/JsonApiDotNetCore/Controllers/Annotations/NoHttpDeleteAttribute.cs b/src/JsonApiDotNetCore/Controllers/Annotations/NoHttpDeleteAttribute.cs deleted file mode 100644 index 86b7ea09b9..0000000000 --- a/src/JsonApiDotNetCore/Controllers/Annotations/NoHttpDeleteAttribute.cs +++ /dev/null @@ -1,22 +0,0 @@ -using JetBrains.Annotations; - -namespace JsonApiDotNetCore.Controllers.Annotations -{ - /// - /// Used on an ASP.NET controller class to indicate the DELETE verb must be blocked. - /// - /// - /// { - /// } - /// ]]> - [PublicAPI] - public sealed class NoHttpDeleteAttribute : HttpRestrictAttribute - { - protected override string[] Methods { get; } = - { - "DELETE" - }; - } -} diff --git a/src/JsonApiDotNetCore/Controllers/Annotations/NoHttpPatchAttribute.cs b/src/JsonApiDotNetCore/Controllers/Annotations/NoHttpPatchAttribute.cs deleted file mode 100644 index 43ca959b5d..0000000000 --- a/src/JsonApiDotNetCore/Controllers/Annotations/NoHttpPatchAttribute.cs +++ /dev/null @@ -1,22 +0,0 @@ -using JetBrains.Annotations; - -namespace JsonApiDotNetCore.Controllers.Annotations -{ - /// - /// Used on an ASP.NET controller class to indicate the PATCH verb must be blocked. - /// - /// - /// { - /// } - /// ]]> - [PublicAPI] - public sealed class NoHttpPatchAttribute : HttpRestrictAttribute - { - protected override string[] Methods { get; } = - { - "PATCH" - }; - } -} diff --git a/src/JsonApiDotNetCore/Controllers/Annotations/NoHttpPostAttribute.cs b/src/JsonApiDotNetCore/Controllers/Annotations/NoHttpPostAttribute.cs deleted file mode 100644 index f5583f6dad..0000000000 --- a/src/JsonApiDotNetCore/Controllers/Annotations/NoHttpPostAttribute.cs +++ /dev/null @@ -1,22 +0,0 @@ -using JetBrains.Annotations; - -namespace JsonApiDotNetCore.Controllers.Annotations -{ - /// - /// Used on an ASP.NET controller class to indicate the POST verb must be blocked. - /// - /// - /// { - /// } - /// ]]> - [PublicAPI] - public sealed class NoHttpPostAttribute : HttpRestrictAttribute - { - protected override string[] Methods { get; } = - { - "POST" - }; - } -} diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index 4d2993a4eb..1e37ec66d0 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -87,7 +87,9 @@ protected BaseJsonApiController(IJsonApiOptions options, IResourceGraph resource } /// - /// Gets a collection of top-level (non-nested) resources. Example: GET /articles HTTP/1.1 + /// Gets a collection of primary resources. Example: /// public virtual async Task GetAsync(CancellationToken cancellationToken) { @@ -95,7 +97,7 @@ public virtual async Task GetAsync(CancellationToken cancellation if (_getAll == null) { - throw new RequestMethodNotAllowedException(HttpMethod.Get); + throw new RouteNotAvailableException(HttpMethod.Get, Request.Path); } IReadOnlyCollection resources = await _getAll.GetAsync(cancellationToken); @@ -104,7 +106,9 @@ public virtual async Task GetAsync(CancellationToken cancellation } /// - /// Gets a single top-level (non-nested) resource by ID. Example: /articles/1 + /// Gets a single primary resource by ID. Example: /// public virtual async Task GetAsync(TId id, CancellationToken cancellationToken) { @@ -115,7 +119,7 @@ public virtual async Task GetAsync(TId id, CancellationToken canc if (_getById == null) { - throw new RequestMethodNotAllowedException(HttpMethod.Get); + throw new RouteNotAvailableException(HttpMethod.Get, Request.Path); } TResource resource = await _getById.GetAsync(id, cancellationToken); @@ -124,7 +128,12 @@ public virtual async Task GetAsync(TId id, CancellationToken canc } /// - /// Gets a single resource or multiple resources at a nested endpoint. Examples: GET /articles/1/author HTTP/1.1 GET /articles/1/revisions HTTP/1.1 + /// Gets a secondary resource or collection of secondary resources. Example: Example: + /// /// public virtual async Task GetSecondaryAsync(TId id, string relationshipName, CancellationToken cancellationToken) { @@ -138,7 +147,7 @@ public virtual async Task GetSecondaryAsync(TId id, string relati if (_getSecondary == null) { - throw new RequestMethodNotAllowedException(HttpMethod.Get); + throw new RouteNotAvailableException(HttpMethod.Get, Request.Path); } object? rightValue = await _getSecondary.GetSecondaryAsync(id, relationshipName, cancellationToken); @@ -147,7 +156,13 @@ public virtual async Task GetSecondaryAsync(TId id, string relati } /// - /// Gets a single resource relationship. Example: GET /articles/1/relationships/author HTTP/1.1 Example: GET /articles/1/relationships/revisions HTTP/1.1 + /// Gets a relationship value, which can be a null, a single object or a collection. Example: + /// Example: + /// /// public virtual async Task GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken) { @@ -161,7 +176,7 @@ public virtual async Task GetRelationshipAsync(TId id, string rel if (_getRelationship == null) { - throw new RequestMethodNotAllowedException(HttpMethod.Get); + throw new RouteNotAvailableException(HttpMethod.Get, Request.Path); } object? rightValue = await _getRelationship.GetRelationshipAsync(id, relationshipName, cancellationToken); @@ -170,7 +185,9 @@ public virtual async Task GetRelationshipAsync(TId id, string rel } /// - /// Creates a new resource with attributes, relationships or both. Example: POST /articles HTTP/1.1 + /// Creates a new resource with attributes, relationships or both. Example: /// public virtual async Task PostAsync([FromBody] TResource resource, CancellationToken cancellationToken) { @@ -183,7 +200,7 @@ public virtual async Task PostAsync([FromBody] TResource resource if (_create == null) { - throw new RequestMethodNotAllowedException(HttpMethod.Post); + throw new RouteNotAvailableException(HttpMethod.Post, Request.Path); } if (_options.ValidateModelState && !ModelState.IsValid) @@ -206,7 +223,9 @@ public virtual async Task PostAsync([FromBody] TResource resource } /// - /// Adds resources to a to-many relationship. Example: POST /articles/1/revisions HTTP/1.1 + /// Adds resources to a to-many relationship. Example: /// /// /// Identifies the left side of the relationship. @@ -235,7 +254,7 @@ public virtual async Task PostRelationshipAsync(TId id, string re if (_addToRelationship == null) { - throw new RequestMethodNotAllowedException(HttpMethod.Post); + throw new RouteNotAvailableException(HttpMethod.Post, Request.Path); } await _addToRelationship.AddToToManyRelationshipAsync(id, relationshipName, rightResourceIds, cancellationToken); @@ -245,7 +264,9 @@ public virtual async Task PostRelationshipAsync(TId id, string re /// /// Updates the attributes and/or relationships of an existing resource. Only the values of sent attributes are replaced. And only the values of sent - /// relationships are replaced. Example: PATCH /articles/1 HTTP/1.1 + /// relationships are replaced. Example: /// public virtual async Task PatchAsync(TId id, [FromBody] TResource resource, CancellationToken cancellationToken) { @@ -259,7 +280,7 @@ public virtual async Task PatchAsync(TId id, [FromBody] TResource if (_update == null) { - throw new RequestMethodNotAllowedException(HttpMethod.Patch); + throw new RouteNotAvailableException(HttpMethod.Patch, Request.Path); } if (_options.ValidateModelState && !ModelState.IsValid) @@ -268,12 +289,18 @@ public virtual async Task PatchAsync(TId id, [FromBody] TResource } TResource? updated = await _update.UpdateAsync(id, resource, cancellationToken); + return updated == null ? NoContent() : Ok(updated); } /// - /// Performs a complete replacement of a relationship on an existing resource. Example: PATCH /articles/1/relationships/author HTTP/1.1 Example: PATCH - /// /articles/1/relationships/revisions HTTP/1.1 + /// Performs a complete replacement of a relationship on an existing resource. Example: + /// Example: + /// /// /// /// Identifies the left side of the relationship. @@ -301,7 +328,7 @@ public virtual async Task PatchRelationshipAsync(TId id, string r if (_setRelationship == null) { - throw new RequestMethodNotAllowedException(HttpMethod.Patch); + throw new RouteNotAvailableException(HttpMethod.Patch, Request.Path); } await _setRelationship.SetRelationshipAsync(id, relationshipName, rightValue, cancellationToken); @@ -310,7 +337,9 @@ public virtual async Task PatchRelationshipAsync(TId id, string r } /// - /// Deletes an existing resource. Example: DELETE /articles/1 HTTP/1.1 + /// Deletes an existing resource. Example: /// public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToken) { @@ -321,7 +350,7 @@ public virtual async Task DeleteAsync(TId id, CancellationToken c if (_delete == null) { - throw new RequestMethodNotAllowedException(HttpMethod.Delete); + throw new RouteNotAvailableException(HttpMethod.Delete, Request.Path); } await _delete.DeleteAsync(id, cancellationToken); @@ -330,7 +359,9 @@ public virtual async Task DeleteAsync(TId id, CancellationToken c } /// - /// Removes resources from a to-many relationship. Example: DELETE /articles/1/relationships/revisions HTTP/1.1 + /// Removes resources from a to-many relationship. Example: /// /// /// Identifies the left side of the relationship. @@ -359,7 +390,7 @@ public virtual async Task DeleteRelationshipAsync(TId id, string if (_removeFromRelationship == null) { - throw new RequestMethodNotAllowedException(HttpMethod.Delete); + throw new RouteNotAvailableException(HttpMethod.Delete, Request.Path); } await _removeFromRelationship.RemoveFromToManyRelationshipAsync(id, relationshipName, rightResourceIds, cancellationToken); diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs index 5c641804e5..bbfa52af89 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs @@ -1,18 +1,13 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Services; -using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCore.Controllers { /// - /// The base class to derive resource-specific write-only controllers from. This class delegates all work to - /// but adds attributes for routing templates. If you want to provide routing templates yourself, - /// you should derive from BaseJsonApiController directly. + /// The base class to derive resource-specific write-only controllers from. Returns HTTP 405 on read-only endpoints. If you want to provide routing + /// templates yourself, you should derive from BaseJsonApiController directly. /// /// /// The resource type. @@ -20,7 +15,7 @@ namespace JsonApiDotNetCore.Controllers /// /// The resource identifier type. /// - public abstract class JsonApiCommandController : BaseJsonApiController + public abstract class JsonApiCommandController : JsonApiController where TResource : class, IIdentifiable { /// @@ -28,53 +23,9 @@ public abstract class JsonApiCommandController : BaseJsonApiCont /// protected JsonApiCommandController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IResourceCommandService commandService) - : base(options, resourceGraph, loggerFactory, null, commandService) + : base(options, resourceGraph, loggerFactory, null, null, null, null, commandService, commandService, commandService, commandService, + commandService, commandService) { } - - /// - [HttpPost] - public override async Task PostAsync([FromBody] TResource resource, CancellationToken cancellationToken) - { - return await base.PostAsync(resource, cancellationToken); - } - - /// - [HttpPost("{id}/relationships/{relationshipName}")] - public override async Task PostRelationshipAsync(TId id, string relationshipName, [FromBody] ISet rightResourceIds, - CancellationToken cancellationToken) - { - return await base.PostRelationshipAsync(id, relationshipName, rightResourceIds, cancellationToken); - } - - /// - [HttpPatch("{id}")] - public override async Task PatchAsync(TId id, [FromBody] TResource resource, CancellationToken cancellationToken) - { - return await base.PatchAsync(id, resource, cancellationToken); - } - - /// - [HttpPatch("{id}/relationships/{relationshipName}")] - public override async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object? rightValue, - CancellationToken cancellationToken) - { - return await base.PatchRelationshipAsync(id, relationshipName, rightValue, cancellationToken); - } - - /// - [HttpDelete("{id}")] - public override async Task DeleteAsync(TId id, CancellationToken cancellationToken) - { - return await base.DeleteAsync(id, cancellationToken); - } - - /// - [HttpDelete("{id}/relationships/{relationshipName}")] - public override async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] ISet rightResourceIds, - CancellationToken cancellationToken) - { - return await base.DeleteRelationshipAsync(id, relationshipName, rightResourceIds, cancellationToken); - } } } diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs index ce4ef04718..d56eab8d60 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs @@ -1,17 +1,13 @@ -using System.Threading; -using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Services; -using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCore.Controllers { /// - /// The base class to derive resource-specific read-only controllers from. This class delegates all work to - /// but adds attributes for routing templates. If you want to provide routing templates yourself, - /// you should derive from BaseJsonApiController directly. + /// The base class to derive resource-specific read-only controllers from. Returns HTTP 405 on write-only endpoints. If you want to provide routing + /// templates yourself, you should derive from BaseJsonApiController directly. /// /// /// The resource type. @@ -19,7 +15,7 @@ namespace JsonApiDotNetCore.Controllers /// /// The resource identifier type. /// - public abstract class JsonApiQueryController : BaseJsonApiController + public abstract class JsonApiQueryController : JsonApiController where TResource : class, IIdentifiable { /// @@ -27,40 +23,8 @@ public abstract class JsonApiQueryController : BaseJsonApiContro /// protected JsonApiQueryController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IResourceQueryService queryService) - : base(options, resourceGraph, loggerFactory, queryService) + : base(options, resourceGraph, loggerFactory, queryService, queryService, queryService, queryService) { } - - /// - [HttpGet] - [HttpHead] - public override async Task GetAsync(CancellationToken cancellationToken) - { - return await base.GetAsync(cancellationToken); - } - - /// - [HttpGet("{id}")] - [HttpHead("{id}")] - public override async Task GetAsync(TId id, CancellationToken cancellationToken) - { - return await base.GetAsync(id, cancellationToken); - } - - /// - [HttpGet("{id}/{relationshipName}")] - [HttpHead("{id}/{relationshipName}")] - public override async Task GetSecondaryAsync(TId id, string relationshipName, CancellationToken cancellationToken) - { - return await base.GetSecondaryAsync(id, relationshipName, cancellationToken); - } - - /// - [HttpGet("{id}/relationships/{relationshipName}")] - [HttpHead("{id}/relationships/{relationshipName}")] - public override async Task GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken) - { - return await base.GetRelationshipAsync(id, relationshipName, cancellationToken); - } } } diff --git a/src/JsonApiDotNetCore/Errors/RequestMethodNotAllowedException.cs b/src/JsonApiDotNetCore/Errors/RequestMethodNotAllowedException.cs deleted file mode 100644 index a4edbc0d5f..0000000000 --- a/src/JsonApiDotNetCore/Errors/RequestMethodNotAllowedException.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.Net; -using System.Net.Http; -using JetBrains.Annotations; -using JsonApiDotNetCore.Serialization.Objects; - -namespace JsonApiDotNetCore.Errors -{ - /// - /// The error that is thrown when a request is received that contains an unsupported HTTP verb. - /// - [PublicAPI] - public sealed class RequestMethodNotAllowedException : JsonApiException - { - public HttpMethod Method { get; } - - public RequestMethodNotAllowedException(HttpMethod method) - : base(new ErrorObject(HttpStatusCode.MethodNotAllowed) - { - Title = "The request method is not allowed.", - Detail = $"Endpoint does not support {method} requests." - }) - { - Method = method; - } - } -} diff --git a/src/JsonApiDotNetCore/Errors/RouteNotAvailableException.cs b/src/JsonApiDotNetCore/Errors/RouteNotAvailableException.cs new file mode 100644 index 0000000000..ed0797efba --- /dev/null +++ b/src/JsonApiDotNetCore/Errors/RouteNotAvailableException.cs @@ -0,0 +1,26 @@ +using System.Net; +using System.Net.Http; +using JetBrains.Annotations; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Errors +{ + /// + /// The error that is thrown when a request is received for an HTTP route that is not exposed. + /// + [PublicAPI] + public sealed class RouteNotAvailableException : JsonApiException + { + public HttpMethod Method { get; } + + public RouteNotAvailableException(HttpMethod method, string route) + : base(new ErrorObject(HttpStatusCode.Forbidden) + { + Title = "The requested endpoint is not accessible.", + Detail = $"Endpoint '{route}' is not accessible for {method} requests." + }) + { + Method = method; + } + } +} diff --git a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj index 026da6fe75..682a136adc 100644 --- a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj +++ b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj @@ -1,6 +1,6 @@ - 4.2.0 + 5.0.0 $(NetCoreAppVersion) true @@ -12,11 +12,20 @@ https://www.jsonapi.net/ MIT false + See https://github.com/json-api-dotnet/JsonApiDotNetCore/releases. + logo.png true true embedded + + + True + + + + diff --git a/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs b/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs index 3a4d009bd8..c1d353851d 100644 --- a/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs +++ b/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs @@ -14,26 +14,26 @@ public interface IJsonApiRequest public EndpointKind Kind { get; } /// - /// The ID of the primary (top-level) resource for this request. This would be null in "/blogs", "123" in "/blogs/123" or "/blogs/123/author". This is + /// The ID of the primary resource for this request. This would be null in "/blogs", "123" in "/blogs/123" or "/blogs/123/author". This is /// null before and after processing operations in an atomic:operations request. /// string? PrimaryId { get; } /// - /// The primary (top-level) resource type for this request. This would be "blogs" in "/blogs", "/blogs/123" or "/blogs/123/author". This is null - /// before and after processing operations in an atomic:operations request. + /// The primary resource type for this request. This would be "blogs" in "/blogs", "/blogs/123" or "/blogs/123/author". This is null before and + /// after processing operations in an atomic:operations request. /// ResourceType? PrimaryResourceType { get; } /// - /// The secondary (nested) resource type for this request. This would be null in "/blogs", "/blogs/123" and "/blogs/123/unknownResource" or "people" in + /// The secondary resource type for this request. This would be null in "/blogs", "/blogs/123" and "/blogs/123/unknownResource" or "people" in /// "/blogs/123/author" and "/blogs/123/relationships/author". This is null before and after processing operations in an atomic:operations /// request. /// ResourceType? SecondaryResourceType { get; } /// - /// The relationship for this nested request. This would be null in "/blogs", "/blogs/123" and "/blogs/123/unknownResource" or "author" in + /// The relationship for this request. This would be null in "/blogs", "/blogs/123" and "/blogs/123/unknownResource" or "author" in /// "/blogs/123/author" and "/blogs/123/relationships/author". This is null before and after processing operations in an atomic:operations /// request. /// @@ -45,13 +45,13 @@ public interface IJsonApiRequest bool IsCollection { get; } /// - /// Indicates whether this request targets only fetching of data (such as resources and relationships). + /// Indicates whether this request targets only fetching of data (resources and relationships), as opposed to applying changes. /// bool IsReadOnly { get; } /// /// In case of a non-readonly request, this indicates the kind of write operation currently being processed. This is null when processing a - /// read-only operations, or before and after processing operations in an atomic:operations request. + /// read-only operation, and before and after processing operations in an atomic:operations request. /// WriteOperationKind? WriteOperation { get; } diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs index 96849fe499..934839e56e 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs @@ -134,7 +134,7 @@ private async Task ValidateIfMatchHeaderAsync(HttpContext httpContext, Jso private static async Task ValidateContentTypeHeaderAsync(string allowedContentType, HttpContext httpContext, JsonSerializerOptions serializerOptions) { - string contentType = httpContext.Request.ContentType; + string? contentType = httpContext.Request.ContentType; // ReSharper disable once ConditionIsAlwaysTrueOrFalse // Justification: Workaround for https://github.com/dotnet/aspnetcore/issues/32097 (fixed in .NET 6) diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs b/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs index 04ceb1c038..d304fc9d1c 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs @@ -162,7 +162,7 @@ private string TemplateFromController(ControllerModel model) if ((nextBaseType == aspNetControllerType || nextBaseType == coreControllerType) && currentType.IsGenericType) { Type? resourceClrType = currentType.GetGenericArguments() - .FirstOrDefault(typeArgument => typeArgument.IsOrImplementsInterface(typeof(IIdentifiable))); + .FirstOrDefault(typeArgument => typeArgument.IsOrImplementsInterface()); if (resourceClrType != null) { diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs index b17255fe3a..8543823199 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs @@ -30,14 +30,14 @@ public override string ToString() { var builder = new StringBuilder(); - foreach ((ResourceType resource, SparseFieldSetExpression fields) in Table) + foreach ((ResourceType resourceType, SparseFieldSetExpression fields) in Table) { if (builder.Length > 0) { builder.Append(','); } - builder.Append(resource.PublicName); + builder.Append(resourceType.PublicName); builder.Append('('); builder.Append(fields); builder.Append(')'); diff --git a/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs index 2fbee698ca..b81c9bacd4 100644 --- a/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs @@ -46,7 +46,7 @@ QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, Resourc /// Builds a query that retrieves the primary resource, including all of its attributes and all targeted relationships, during a create/update/delete /// request. /// - QueryLayer ComposeForUpdate(TId id, ResourceType primaryResource); + QueryLayer ComposeForUpdate(TId id, ResourceType primaryResourceType); /// /// Builds a query for each targeted relationship with a filter to match on its right resource IDs. diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs index 2f8965070a..d1a55551de 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs @@ -382,16 +382,16 @@ private IncludeExpression RewriteIncludeForSecondaryEndpoint(IncludeExpression? } /// - public QueryLayer ComposeForUpdate(TId id, ResourceType primaryResource) + public QueryLayer ComposeForUpdate(TId id, ResourceType primaryResourceType) { - ArgumentGuard.NotNull(primaryResource, nameof(primaryResource)); + ArgumentGuard.NotNull(primaryResourceType, nameof(primaryResourceType)); IImmutableSet includeElements = _targetedFields.Relationships .Select(relationship => new IncludeElementExpression(relationship)).ToImmutableHashSet(); - AttrAttribute primaryIdAttribute = GetIdAttribute(primaryResource); + AttrAttribute primaryIdAttribute = GetIdAttribute(primaryResourceType); - QueryLayer primaryLayer = ComposeTopLayer(Array.Empty(), primaryResource); + QueryLayer primaryLayer = ComposeTopLayer(Array.Empty(), primaryResourceType); primaryLayer.Include = includeElements.Any() ? new IncludeExpression(includeElements) : IncludeExpression.Empty; primaryLayer.Sort = null; primaryLayer.Pagination = null; diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs index 5a8d990225..c34b92c0dc 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs @@ -86,38 +86,57 @@ private ICollection ToPropertySelectors(IDictionary(); - // If a read-only attribute is selected, its value likely depends on another property, so select all resource properties. + // If a read-only attribute is selected, its calculated value likely depends on another property, so select all properties. bool includesReadOnlyAttribute = resourceFieldSelectors.Any(selector => selector.Key is AttrAttribute attribute && attribute.Property.SetMethod == null); + // Only selecting relationships implicitly means to select all attributes too. bool containsOnlyRelationships = resourceFieldSelectors.All(selector => selector.Key is RelationshipAttribute); - foreach ((ResourceFieldAttribute resourceField, QueryLayer? queryLayer) in resourceFieldSelectors) + if (includesReadOnlyAttribute || containsOnlyRelationships) { - var propertySelector = new PropertySelector(resourceField.Property, queryLayer); - - if (propertySelector.Property.SetMethod != null) - { - propertySelectors[propertySelector.Property] = propertySelector; - } + IncludeAllProperties(elementType, propertySelectors); } - if (includesReadOnlyAttribute || containsOnlyRelationships) + IncludeFieldSelection(resourceFieldSelectors, propertySelectors); + + IncludeEagerLoads(resourceType, propertySelectors); + + return propertySelectors.Values; + } + + private void IncludeAllProperties(Type elementType, Dictionary propertySelectors) + { + IEntityType entityModel = _entityModel.GetEntityTypes().Single(type => type.ClrType == elementType); + IEnumerable entityProperties = entityModel.GetProperties().Where(property => !property.IsShadowProperty()).ToArray(); + + foreach (IProperty entityProperty in entityProperties) { - IEntityType entityModel = _entityModel.GetEntityTypes().Single(type => type.ClrType == elementType); - IEnumerable entityProperties = entityModel.GetProperties().Where(property => !property.IsShadowProperty()).ToArray(); + var propertySelector = new PropertySelector(entityProperty.PropertyInfo); + IncludeWritableProperty(propertySelector, propertySelectors); + } + } - foreach (IProperty entityProperty in entityProperties) - { - var propertySelector = new PropertySelector(entityProperty.PropertyInfo); + private static void IncludeFieldSelection(IDictionary resourceFieldSelectors, + Dictionary propertySelectors) + { + foreach ((ResourceFieldAttribute resourceField, QueryLayer? queryLayer) in resourceFieldSelectors) + { + var propertySelector = new PropertySelector(resourceField.Property, queryLayer); + IncludeWritableProperty(propertySelector, propertySelectors); + } + } - if (propertySelector.Property.SetMethod != null) - { - propertySelectors[propertySelector.Property] = propertySelector; - } - } + private static void IncludeWritableProperty(PropertySelector propertySelector, Dictionary propertySelectors) + { + if (propertySelector.Property.SetMethod != null) + { + propertySelectors[propertySelector.Property] = propertySelector; } + } + private static void IncludeEagerLoads(ResourceType resourceType, Dictionary propertySelectors) + { foreach (EagerLoadAttribute eagerLoad in resourceType.EagerLoads) { var propertySelector = new PropertySelector(eagerLoad.Property); @@ -128,11 +147,7 @@ private ICollection ToPropertySelectors(IDictionary - /// Indicates whether this reader supports empty query string parameter values. Defaults to false. + /// Indicates whether this reader supports empty query string parameter values. /// - bool AllowEmptyValue => false; + bool AllowEmptyValue { get; } /// /// Indicates whether usage of this query string parameter is blocked using on a controller. diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs index 245aa7a787..30d1e5d904 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs @@ -29,6 +29,8 @@ public class FilterQueryStringParameterReader : QueryStringParameterReader, IFil private string? _lastParameterName; + public bool AllowEmptyValue => false; + public FilterQueryStringParameterReader(IJsonApiRequest request, IResourceGraph resourceGraph, IResourceFactory resourceFactory, IJsonApiOptions options) : base(request, resourceGraph) diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs index 77a5bce864..f5e98b9a20 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs @@ -21,6 +21,8 @@ public class IncludeQueryStringParameterReader : QueryStringParameterReader, IIn private IncludeExpression? _includeExpression; private string? _lastParameterName; + public bool AllowEmptyValue => false; + public IncludeQueryStringParameterReader(IJsonApiRequest request, IResourceGraph resourceGraph, IJsonApiOptions options) : base(request, resourceGraph) { diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/PaginationQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/PaginationQueryStringParameterReader.cs index 2dc722c621..023a4a67bd 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/PaginationQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/PaginationQueryStringParameterReader.cs @@ -25,6 +25,8 @@ public class PaginationQueryStringParameterReader : QueryStringParameterReader, private PaginationQueryStringValueExpression? _pageSizeConstraint; private PaginationQueryStringValueExpression? _pageNumberConstraint; + public bool AllowEmptyValue => false; + public PaginationQueryStringParameterReader(IJsonApiRequest request, IResourceGraph resourceGraph, IJsonApiOptions options) : base(request, resourceGraph) { diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/ResourceDefinitionQueryableParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/ResourceDefinitionQueryableParameterReader.cs index 125700b0a3..bc53a9ed4d 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/ResourceDefinitionQueryableParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/ResourceDefinitionQueryableParameterReader.cs @@ -19,6 +19,8 @@ public class ResourceDefinitionQueryableParameterReader : IResourceDefinitionQue private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; private readonly List _constraints = new(); + public bool AllowEmptyValue => false; + public ResourceDefinitionQueryableParameterReader(IJsonApiRequest request, IResourceDefinitionAccessor resourceDefinitionAccessor) { ArgumentGuard.NotNull(request, nameof(request)); diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/SortQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/SortQueryStringParameterReader.cs index 80f3510766..d231f176f5 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/SortQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/SortQueryStringParameterReader.cs @@ -21,6 +21,8 @@ public class SortQueryStringParameterReader : QueryStringParameterReader, ISortQ private readonly List _constraints = new(); private string? _lastParameterName; + public bool AllowEmptyValue => false; + public SortQueryStringParameterReader(IJsonApiRequest request, IResourceGraph resourceGraph) : base(request, resourceGraph) { diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs index 111f82b7c2..07ba665f18 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs @@ -69,10 +69,10 @@ public virtual void Read(string parameterName, StringValues parameterValue) try { - ResourceType targetResource = GetSparseFieldType(parameterName); - SparseFieldSetExpression sparseFieldSet = GetSparseFieldSet(parameterValue, targetResource); + ResourceType targetResourceType = GetSparseFieldType(parameterName); + SparseFieldSetExpression sparseFieldSet = GetSparseFieldSet(parameterValue, targetResourceType); - _sparseFieldTableBuilder[targetResource] = sparseFieldSet; + _sparseFieldTableBuilder[targetResourceType] = sparseFieldSet; } catch (QueryParseException exception) { diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 3504b0b5ec..2092f8935a 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -379,7 +379,7 @@ private bool RequiresLoadOfRelationshipForDeletion(RelationshipAttribute relatio private INavigation? GetNavigation(RelationshipAttribute relationship) { - IEntityType entityType = _dbContext.Model.FindEntityType(typeof(TResource)); + IEntityType? entityType = _dbContext.Model.FindEntityType(typeof(TResource)); return entityType?.FindNavigation(relationship.Property.Name); } @@ -500,15 +500,18 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TResource leftResour HashSet rightResourceIdsToStore = rightResourceIdsStored.ToHashSet(IdentifiableComparer.Instance); rightResourceIdsToStore.ExceptWith(rightResourceIdsToRemove); - AssertIsNotClearingRequiredToOneRelationship(relationship, leftResourceTracked, rightResourceIdsToStore); + if (!rightResourceIdsToStore.SetEquals(rightResourceIdsStored)) + { + AssertIsNotClearingRequiredToOneRelationship(relationship, leftResourceTracked, rightResourceIdsToStore); - await UpdateRelationshipAsync(relationship, leftResourceTracked, rightResourceIdsToStore, cancellationToken); + await UpdateRelationshipAsync(relationship, leftResourceTracked, rightResourceIdsToStore, cancellationToken); - await _resourceDefinitionAccessor.OnWritingAsync(leftResourceTracked, WriteOperationKind.RemoveFromRelationship, cancellationToken); + await _resourceDefinitionAccessor.OnWritingAsync(leftResourceTracked, WriteOperationKind.RemoveFromRelationship, cancellationToken); - await SaveChangesAsync(cancellationToken); + await SaveChangesAsync(cancellationToken); - await _resourceDefinitionAccessor.OnWriteSucceededAsync(leftResourceTracked, WriteOperationKind.RemoveFromRelationship, cancellationToken); + await _resourceDefinitionAccessor.OnWriteSucceededAsync(leftResourceTracked, WriteOperationKind.RemoveFromRelationship, cancellationToken); + } } } @@ -568,13 +571,6 @@ protected virtual async Task SaveChangesAsync(CancellationToken cancellationToke } catch (Exception exception) when (exception is DbUpdateException or InvalidOperationException) { - if (_dbContext.Database.CurrentTransaction != null) - { - // The ResourceService calling us needs to run additional SQL queries after an aborted transaction, - // to determine error cause. This fails when a failed transaction is still in progress. - await _dbContext.Database.CurrentTransaction.RollbackAsync(cancellationToken); - } - _dbContext.ResetChangeTracker(); throw new DataStoreUpdateException(exception); diff --git a/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs b/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs index 7a7102291e..2adf4c2e2c 100644 --- a/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs +++ b/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs @@ -6,15 +6,17 @@ namespace JsonApiDotNetCore.Resources { internal static class IdentifiableExtensions { + private const string IdPropertyName = nameof(Identifiable.Id); + public static object GetTypedId(this IIdentifiable identifiable) { ArgumentGuard.NotNull(identifiable, nameof(identifiable)); - PropertyInfo? property = identifiable.GetType().GetProperty(nameof(Identifiable.Id)); + PropertyInfo? property = identifiable.GetType().GetProperty(IdPropertyName); if (property == null) { - throw new InvalidOperationException($"Resource of type '{identifiable.GetType()}' does not have an 'Id' property."); + throw new InvalidOperationException($"Resource of type '{identifiable.GetType()}' does not contain a property named '{IdPropertyName}'."); } object? propertyValue = property.GetValue(identifiable); @@ -26,7 +28,7 @@ public static object GetTypedId(this IIdentifiable identifiable) if (Equals(propertyValue, defaultValue)) { - throw new InvalidOperationException($"Property '{identifiable.GetType().Name}.{nameof(Identifiable.Id)}' should " + + throw new InvalidOperationException($"Property '{identifiable.GetType().Name}.{IdPropertyName}' should " + $"have been assigned at this point, but it contains its default {property.PropertyType.Name} value '{propertyValue}'."); } } diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicOperationObjectAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicOperationObjectAdapter.cs index f21d1181ee..86f94918a5 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicOperationObjectAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicOperationObjectAdapter.cs @@ -101,7 +101,7 @@ private WriteOperationKind ConvertOperationCode(AtomicOperationObject atomicOper private (ResourceIdentityRequirements requirements, IIdentifiable? primaryResource) ConvertRef(AtomicOperationObject atomicOperationObject, RequestAdapterState state) { - ResourceIdentityRequirements requirements = CreateIdentityRequirements(state); + ResourceIdentityRequirements requirements = CreateDataRequirements(state); IIdentifiable? primaryResource = null; AtomicReferenceResult? refResult = atomicOperationObject.Ref != null @@ -110,15 +110,6 @@ private WriteOperationKind ConvertOperationCode(AtomicOperationObject atomicOper if (refResult != null) { - requirements = new ResourceIdentityRequirements - { - ResourceType = refResult.ResourceType, - IdConstraint = requirements.IdConstraint, - IdValue = refResult.Resource.StringId, - LidValue = refResult.Resource.LocalId, - RelationshipName = refResult.Relationship?.PublicName - }; - state.WritableRequest!.PrimaryId = refResult.Resource.StringId; state.WritableRequest.PrimaryResourceType = refResult.ResourceType; state.WritableRequest.Relationship = refResult.Relationship; @@ -126,13 +117,14 @@ private WriteOperationKind ConvertOperationCode(AtomicOperationObject atomicOper ConvertRefRelationship(atomicOperationObject.Data, refResult, state); + requirements = CreateRefRequirements(refResult, requirements); primaryResource = refResult.Resource; } return (requirements, primaryResource); } - private ResourceIdentityRequirements CreateIdentityRequirements(RequestAdapterState state) + private ResourceIdentityRequirements CreateDataRequirements(RequestAdapterState state) { JsonElementConstraint? idConstraint = state.Request.WriteOperation == WriteOperationKind.CreateResource ? _options.AllowClientGeneratedIds ? null : JsonElementConstraint.Forbidden @@ -144,6 +136,18 @@ private ResourceIdentityRequirements CreateIdentityRequirements(RequestAdapterSt }; } + private static ResourceIdentityRequirements CreateRefRequirements(AtomicReferenceResult refResult, ResourceIdentityRequirements dataRequirements) + { + return new ResourceIdentityRequirements + { + ResourceType = refResult.ResourceType, + IdConstraint = dataRequirements.IdConstraint, + IdValue = refResult.Resource.StringId, + LidValue = refResult.Resource.LocalId, + RelationshipName = refResult.Relationship?.PublicName + }; + } + private void ConvertRefRelationship(SingleOrManyData relationshipData, AtomicReferenceResult refResult, RequestAdapterState state) { if (refResult.Relationship != null) diff --git a/src/JsonApiDotNetCore/Serialization/Response/ILinkBuilder.cs b/src/JsonApiDotNetCore/Serialization/Response/ILinkBuilder.cs index db16a774dd..891556c7af 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/ILinkBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/ILinkBuilder.cs @@ -1,4 +1,5 @@ using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; @@ -17,11 +18,11 @@ public interface ILinkBuilder /// /// Builds the links object for a returned resource (primary or included). /// - ResourceLinks? GetResourceLinks(ResourceType resourceType, string id); + ResourceLinks? GetResourceLinks(ResourceType resourceType, IIdentifiable resource); /// /// Builds the links object for a relationship inside a returned resource. /// - RelationshipLinks? GetRelationshipLinks(RelationshipAttribute relationship, string leftId); + RelationshipLinks? GetRelationshipLinks(RelationshipAttribute relationship, IIdentifiable leftResource); } } diff --git a/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs b/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs index 50fd7d61e9..1b95266000 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs @@ -228,16 +228,16 @@ private string GetQueryStringInPaginationLink(int pageOffset, string? pageSizeVa } /// - public ResourceLinks? GetResourceLinks(ResourceType resourceType, string id) + public ResourceLinks? GetResourceLinks(ResourceType resourceType, IIdentifiable resource) { ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - ArgumentGuard.NotNullNorEmpty(id, nameof(id)); + ArgumentGuard.NotNull(resource, nameof(resource)); var links = new ResourceLinks(); if (ShouldIncludeResourceLink(LinkTypes.Self, resourceType)) { - links.Self = GetLinkForResourceSelf(resourceType, id); + links.Self = GetLinkForResourceSelf(resourceType, resource); } return links.HasValue() ? links : null; @@ -257,36 +257,36 @@ private bool ShouldIncludeResourceLink(LinkTypes linkType, ResourceType resource return _options.ResourceLinks.HasFlag(linkType); } - private string GetLinkForResourceSelf(ResourceType resourceType, string resourceId) + private string? GetLinkForResourceSelf(ResourceType resourceType, IIdentifiable resource) { string? controllerName = _controllerResourceMapping.GetControllerNameForResourceType(resourceType); - IDictionary routeValues = GetRouteValues(resourceId, null); + IDictionary routeValues = GetRouteValues(resource.StringId!, null); return RenderLinkForAction(controllerName, GetPrimaryControllerActionName, routeValues); } /// - public RelationshipLinks? GetRelationshipLinks(RelationshipAttribute relationship, string leftId) + public RelationshipLinks? GetRelationshipLinks(RelationshipAttribute relationship, IIdentifiable leftResource) { ArgumentGuard.NotNull(relationship, nameof(relationship)); - ArgumentGuard.NotNullNorEmpty(leftId, nameof(leftId)); + ArgumentGuard.NotNull(leftResource, nameof(leftResource)); var links = new RelationshipLinks(); if (ShouldIncludeRelationshipLink(LinkTypes.Self, relationship)) { - links.Self = GetLinkForRelationshipSelf(leftId, relationship); + links.Self = GetLinkForRelationshipSelf(leftResource.StringId!, relationship); } if (ShouldIncludeRelationshipLink(LinkTypes.Related, relationship)) { - links.Related = GetLinkForRelationshipRelated(leftId, relationship); + links.Related = GetLinkForRelationshipRelated(leftResource.StringId!, relationship); } return links.HasValue() ? links : null; } - private string GetLinkForRelationshipSelf(string leftId, RelationshipAttribute relationship) + private string? GetLinkForRelationshipSelf(string leftId, RelationshipAttribute relationship) { string? controllerName = _controllerResourceMapping.GetControllerNameForResourceType(relationship.LeftType); IDictionary routeValues = GetRouteValues(leftId, relationship.PublicName); @@ -294,7 +294,7 @@ private string GetLinkForRelationshipSelf(string leftId, RelationshipAttribute r return RenderLinkForAction(controllerName, GetRelationshipControllerActionName, routeValues); } - private string GetLinkForRelationshipRelated(string leftId, RelationshipAttribute relationship) + private string? GetLinkForRelationshipRelated(string leftId, RelationshipAttribute relationship) { string? controllerName = _controllerResourceMapping.GetControllerNameForResourceType(relationship.LeftType); IDictionary routeValues = GetRouteValues(leftId, relationship.PublicName); @@ -315,11 +315,11 @@ private string GetLinkForRelationshipRelated(string leftId, RelationshipAttribut return routeValues; } - protected virtual string RenderLinkForAction(string? controllerName, string actionName, IDictionary routeValues) + protected virtual string? RenderLinkForAction(string? controllerName, string actionName, IDictionary routeValues) { return _options.UseRelativeLinks - ? _linkGenerator.GetPathByAction(_httpContextAccessor.HttpContext, actionName, controllerName, routeValues) - : _linkGenerator.GetUriByAction(_httpContextAccessor.HttpContext, actionName, controllerName, routeValues); + ? _linkGenerator.GetPathByAction(HttpContext, actionName, controllerName, routeValues) + : _linkGenerator.GetUriByAction(HttpContext, actionName, controllerName, routeValues); } /// diff --git a/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs b/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs index f60ee1a288..f33f566249 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs @@ -27,25 +27,25 @@ internal sealed class ResourceObjectTreeNode : IEquatable>? _childrenByRelationship; - private bool IsTreeRoot => RootType.Equals(Type); + private bool IsTreeRoot => RootType.Equals(ResourceType); // The resource this node was built for. We only store it for the LinkBuilder. public IIdentifiable Resource { get; } // The resource type. We use its relationships to maintain order. - public ResourceType Type { get; } + public ResourceType ResourceType { get; } // The produced resource object from Resource. For each resource, at most one ResourceObject and one tree node must exist. public ResourceObject ResourceObject { get; } - public ResourceObjectTreeNode(IIdentifiable resource, ResourceType type, ResourceObject resourceObject) + public ResourceObjectTreeNode(IIdentifiable resource, ResourceType resourceType, ResourceObject resourceObject) { ArgumentGuard.NotNull(resource, nameof(resource)); - ArgumentGuard.NotNull(type, nameof(type)); + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); ArgumentGuard.NotNull(resourceObject, nameof(resourceObject)); Resource = resource; - Type = type; + ResourceType = resourceType; ResourceObject = resourceObject; } @@ -133,7 +133,7 @@ private static void VisitRelationshipChildrenInSubtree(ResourceObjectTreeNode tr { if (treeNode._childrenByRelationship != null) { - foreach (RelationshipAttribute relationship in treeNode.Type.Relationships) + foreach (RelationshipAttribute relationship in treeNode.ResourceType.Relationships) { if (treeNode._childrenByRelationship.TryGetValue(relationship, out HashSet? rightNodes)) { @@ -228,7 +228,7 @@ public override int GetHashCode() public override string ToString() { var builder = new StringBuilder(); - builder.Append(IsTreeRoot ? Type.PublicName : $"{ResourceObject.Type}:{ResourceObject.Id}"); + builder.Append(IsTreeRoot ? ResourceType.PublicName : $"{ResourceObject.Type}:{ResourceObject.Id}"); if (_directChildren != null) { diff --git a/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs b/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs index 40dd7c045c..1d20316c1e 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs @@ -151,10 +151,10 @@ protected virtual AtomicResultObject ConvertOperation(OperationContainer? operat }; } - private void TraverseResource(IIdentifiable resource, ResourceType type, EndpointKind kind, IImmutableSet includeElements, - ResourceObjectTreeNode parentTreeNode, RelationshipAttribute? parentRelationship) + private void TraverseResource(IIdentifiable resource, ResourceType resourceType, EndpointKind kind, + IImmutableSet includeElements, ResourceObjectTreeNode parentTreeNode, RelationshipAttribute? parentRelationship) { - ResourceObjectTreeNode treeNode = GetOrCreateTreeNode(resource, type, kind); + ResourceObjectTreeNode treeNode = GetOrCreateTreeNode(resource, resourceType, kind); if (parentRelationship != null) { @@ -171,12 +171,12 @@ private void TraverseResource(IIdentifiable resource, ResourceType type, Endpoin } } - private ResourceObjectTreeNode GetOrCreateTreeNode(IIdentifiable resource, ResourceType type, EndpointKind kind) + private ResourceObjectTreeNode GetOrCreateTreeNode(IIdentifiable resource, ResourceType resourceType, EndpointKind kind) { if (!_resourceToTreeNodeCache.TryGetValue(resource, out ResourceObjectTreeNode? treeNode)) { - ResourceObject resourceObject = ConvertResource(resource, type, kind); - treeNode = new ResourceObjectTreeNode(resource, type, resourceObject); + ResourceObject resourceObject = ConvertResource(resource, resourceType, kind); + treeNode = new ResourceObjectTreeNode(resource, resourceType, resourceObject); _resourceToTreeNodeCache.Add(resource, treeNode); } @@ -184,7 +184,7 @@ private ResourceObjectTreeNode GetOrCreateTreeNode(IIdentifiable resource, Resou return treeNode; } - protected virtual ResourceObject ConvertResource(IIdentifiable resource, ResourceType type, EndpointKind kind) + protected virtual ResourceObject ConvertResource(IIdentifiable resource, ResourceType resourceType, EndpointKind kind) { bool isRelationship = kind == EndpointKind.Relationship; @@ -195,17 +195,17 @@ protected virtual ResourceObject ConvertResource(IIdentifiable resource, Resourc var resourceObject = new ResourceObject { - Type = type.PublicName, + Type = resourceType.PublicName, Id = resource.StringId }; if (!isRelationship) { - IImmutableSet fieldSet = _sparseFieldSetCache.GetSparseFieldSetForSerializer(type); + IImmutableSet fieldSet = _sparseFieldSetCache.GetSparseFieldSetForSerializer(resourceType); - resourceObject.Attributes = ConvertAttributes(resource, type, fieldSet); - resourceObject.Links = _linkBuilder.GetResourceLinks(type, resource.StringId!); - resourceObject.Meta = _resourceDefinitionAccessor.GetMeta(type, resource); + resourceObject.Attributes = ConvertAttributes(resource, resourceType, fieldSet); + resourceObject.Links = _linkBuilder.GetResourceLinks(resourceType, resource); + resourceObject.Meta = _resourceDefinitionAccessor.GetMeta(resourceType, resource); } return resourceObject; @@ -278,9 +278,9 @@ private void PopulateRelationshipsInTree(ResourceObjectTreeNode rootNode, Endpoi private void PopulateRelationshipsInResourceObject(ResourceObjectTreeNode treeNode) { - IImmutableSet fieldSet = _sparseFieldSetCache.GetSparseFieldSetForSerializer(treeNode.Type); + IImmutableSet fieldSet = _sparseFieldSetCache.GetSparseFieldSetForSerializer(treeNode.ResourceType); - foreach (RelationshipAttribute relationship in treeNode.Type.Relationships) + foreach (RelationshipAttribute relationship in treeNode.ResourceType.Relationships) { if (fieldSet.Contains(relationship)) { @@ -292,7 +292,7 @@ private void PopulateRelationshipsInResourceObject(ResourceObjectTreeNode treeNo private void PopulateRelationshipInResourceObject(ResourceObjectTreeNode treeNode, RelationshipAttribute relationship) { SingleOrManyData data = GetRelationshipData(treeNode, relationship); - RelationshipLinks? links = _linkBuilder.GetRelationshipLinks(relationship, treeNode.Resource.StringId!); + RelationshipLinks? links = _linkBuilder.GetRelationshipLinks(relationship, treeNode.Resource); if (links != null || data.IsAssigned) { @@ -315,7 +315,7 @@ private static SingleOrManyData GetRelationshipData(Re { IEnumerable resourceIdentifierObjects = rightNodes.Select(rightNode => new ResourceIdentifierObject { - Type = rightNode.Type.PublicName, + Type = rightNode.ResourceType.PublicName, Id = rightNode.ResourceObject.Id }); diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 8ddfaf8bce..dab5cd54f2 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -226,17 +226,7 @@ private async Task RetrieveResourceCountForNonPrimaryEndpointAsync(TId id, HasMa } catch (DataStoreUpdateException) { - if (!Equals(resourceFromRequest.Id, default(TId))) - { - TResource? existingResource = - await GetPrimaryResourceByIdOrDefaultAsync(resourceFromRequest.Id, TopFieldSelection.OnlyIdAttribute, cancellationToken); - - if (existingResource != null) - { - throw new ResourceAlreadyExistsException(resourceFromRequest.StringId!, _request.PrimaryResourceType.PublicName); - } - } - + await AssertPrimaryResourceDoesNotExistAsync(resourceFromRequest, cancellationToken); await AssertResourcesToAssignInRelationshipsExistAsync(resourceFromRequest, cancellationToken); throw; } @@ -249,6 +239,19 @@ private async Task RetrieveResourceCountForNonPrimaryEndpointAsync(TId id, HasMa return hasImplicitChanges ? resourceFromDatabase : null; } + protected async Task AssertPrimaryResourceDoesNotExistAsync(TResource resource, CancellationToken cancellationToken) + { + if (!Equals(resource.Id, default(TId))) + { + TResource? existingResource = await GetPrimaryResourceByIdOrDefaultAsync(resource.Id, TopFieldSelection.OnlyIdAttribute, cancellationToken); + + if (existingResource != null) + { + throw new ResourceAlreadyExistsException(resource.StringId!, _request.PrimaryResourceType!.PublicName); + } + } + } + protected virtual async Task InitializeResourceAsync(TResource resourceForDatabase, CancellationToken cancellationToken) { await _resourceDefinitionAccessor.OnPrepareWriteAsync(resourceForDatabase, WriteOperationKind.CreateResource, cancellationToken); @@ -347,9 +350,7 @@ private async Task GetForHasManyUpdateAsync(HasManyAttribute hasManyR CancellationToken cancellationToken) { QueryLayer queryLayer = _queryLayerComposer.ComposeForHasMany(hasManyRelationship, leftId, rightResourceIds); - IReadOnlyCollection leftResources = await _repositoryAccessor.GetAsync(queryLayer, cancellationToken); - - TResource? leftResource = leftResources.FirstOrDefault(); + var leftResource = await _repositoryAccessor.GetForUpdateAsync(queryLayer, cancellationToken); AssertPrimaryResourceExists(leftResource); return leftResource; @@ -517,8 +518,8 @@ protected async Task GetPrimaryResourceForUpdateAsync(TId id, Cancell QueryLayer queryLayer = _queryLayerComposer.ComposeForUpdate(id, _request.PrimaryResourceType); var resource = await _repositoryAccessor.GetForUpdateAsync(queryLayer, cancellationToken); - AssertPrimaryResourceExists(resource); + return resource; } diff --git a/src/JsonApiDotNetCore/TypeExtensions.cs b/src/JsonApiDotNetCore/TypeExtensions.cs index 41450afdf7..18e6dc6967 100644 --- a/src/JsonApiDotNetCore/TypeExtensions.cs +++ b/src/JsonApiDotNetCore/TypeExtensions.cs @@ -8,7 +8,15 @@ internal static class TypeExtensions /// /// Whether the specified source type implements or equals the specified interface. /// - public static bool IsOrImplementsInterface(this Type? source, Type interfaceType) + public static bool IsOrImplementsInterface(this Type? source) + { + return IsOrImplementsInterface(source, typeof(TInterface)); + } + + /// + /// Whether the specified source type implements or equals the specified interface. This overload enables to test for an open generic interface. + /// + private static bool IsOrImplementsInterface(this Type? source, Type interfaceType) { ArgumentGuard.NotNull(interfaceType, nameof(interfaceType)); @@ -17,7 +25,13 @@ public static bool IsOrImplementsInterface(this Type? source, Type interfaceType return false; } - return source == interfaceType || source.GetInterfaces().Any(type => type == interfaceType); + return AreTypesEqual(interfaceType, source, interfaceType.IsGenericType) || + source.GetInterfaces().Any(type => AreTypesEqual(interfaceType, type, interfaceType.IsGenericType)); + } + + private static bool AreTypesEqual(Type left, Type right, bool isLeftGeneric) + { + return isLeftGeneric ? right.IsGenericType && right.GetGenericTypeDefinition() == left : left == right; } /// diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs index 73f5e10f5c..35df2904e3 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs @@ -13,7 +13,7 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; namespace JsonApiDotNetCoreTests.IntegrationTests.Archiving { @@ -134,8 +134,7 @@ public override async Task OnWritingAsync(TelevisionBroadcast broadcast, WriteOp } else if (writeOperation == WriteOperationKind.DeleteResource) { - TelevisionBroadcast broadcastToDelete = - await _dbContext.Broadcasts.FirstOrDefaultAsync(resource => resource.Id == broadcast.Id, cancellationToken); + TelevisionBroadcast? broadcastToDelete = await _dbContext.Broadcasts.FirstWithIdAsync(broadcast.Id, cancellationToken); if (broadcastToDelete != null) { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicRollbackTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicRollbackTests.cs index 02c03b9fac..8b4c1d49f1 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicRollbackTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicRollbackTests.cs @@ -36,7 +36,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.ClearTablesAsync(); }); - string performerId = Unknown.StringId.For(); + string unknownPerformerId = Unknown.StringId.For(); var requestBody = new { @@ -74,7 +74,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => new { type = "performers", - id = performerId + id = unknownPerformerId } } } @@ -97,7 +97,87 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); error.Title.Should().Be("A related resource does not exist."); - error.Detail.Should().Be($"Related resource of type 'performers' with ID '{performerId}' in relationship 'performers' does not exist."); + error.Detail.Should().Be($"Related resource of type 'performers' with ID '{unknownPerformerId}' in relationship 'performers' does not exist."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[1]"); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List performersInDatabase = await dbContext.Performers.ToListAsync(); + performersInDatabase.Should().BeEmpty(); + + List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); + tracksInDatabase.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Can_restore_to_previous_savepoint_on_error() + { + // Arrange + string newTrackTitle = _fakers.MusicTrack.Generate().Title; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTablesAsync(); + }); + + const string trackLid = "track-1"; + + string unknownPerformerId = Unknown.StringId.For(); + + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + lid = trackLid, + attributes = new + { + title = newTrackTitle + } + } + }, + new + { + op = "add", + @ref = new + { + type = "musicTracks", + lid = trackLid, + relationship = "performers" + }, + data = new[] + { + new + { + type = "performers", + id = unknownPerformerId + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + error.Detail.Should().Be($"Related resource of type 'performers' with ID '{unknownPerformerId}' in relationship 'performers' does not exist."); error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[1]"); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/LyricRepository.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/LyricRepository.cs index fd9dd6afa3..d6e25823bf 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/LyricRepository.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/LyricRepository.cs @@ -13,7 +13,7 @@ public sealed class LyricRepository : EntityFrameworkCoreRepository { private readonly ExtraDbContext _extraDbContext; - public override string TransactionId => _extraDbContext.Database.CurrentTransaction.TransactionId.ToString(); + public override string? TransactionId => _extraDbContext.Database.CurrentTransaction?.TransactionId.ToString(); public LyricRepository(ExtraDbContext extraDbContext, ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, IResourceFactory resourceFactory, IEnumerable constraintProviders, diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs index 8f99e777b4..7ad93bdec6 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs @@ -186,7 +186,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - Car carInDatabase = await dbContext.Cars.FirstOrDefaultAsync(car => car.RegionId == newCar.RegionId && car.LicensePlate == newCar.LicensePlate); + Car? carInDatabase = + await dbContext.Cars.FirstOrDefaultAsync(car => car.RegionId == newCar.RegionId && car.LicensePlate == newCar.LicensePlate); carInDatabase.ShouldNotBeNull(); carInDatabase.Id.Should().Be($"{newCar.RegionId}:{newCar.LicensePlate}"); @@ -508,7 +509,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - Car carInDatabase = + Car? carInDatabase = await dbContext.Cars.FirstOrDefaultAsync(car => car.RegionId == existingCar.RegionId && car.LicensePlate == existingCar.LicensePlate); carInDatabase.Should().BeNull(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Blog.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Blog.cs index 97d11fd04d..f421a0d67c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Blog.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Blog.cs @@ -22,6 +22,6 @@ public sealed class Blog : Identifiable public IList Posts { get; set; } = new List(); [HasOne] - public WebAccount Owner { get; set; } = null!; + public WebAccount? Owner { get; set; } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Comment.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Comment.cs index d0e0be237f..1e0b6b5b6e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Comment.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Comment.cs @@ -15,7 +15,7 @@ public sealed class Comment : Identifiable public DateTime CreatedAt { get; set; } [HasOne] - public WebAccount Author { get; set; } = null!; + public WebAccount? Author { get; set; } [HasOne] public BlogPost Parent { get; set; } = null!; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDepthTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDepthTests.cs index bfd6beae19..92dce6788a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDepthTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDepthTests.cs @@ -502,13 +502,13 @@ public async Task Can_filter_in_multiple_scopes() List blogs = _fakers.Blog.Generate(2); blogs[1].Title = "Technology"; blogs[1].Owner = _fakers.WebAccount.Generate(); - blogs[1].Owner.UserName = "Smith"; - blogs[1].Owner.Posts = _fakers.BlogPost.Generate(2); - blogs[1].Owner.Posts[0].Caption = "One"; - blogs[1].Owner.Posts[1].Caption = "Two"; - blogs[1].Owner.Posts[1].Comments = _fakers.Comment.Generate(2).ToHashSet(); - blogs[1].Owner.Posts[1].Comments.ElementAt(0).CreatedAt = 1.January(2000); - blogs[1].Owner.Posts[1].Comments.ElementAt(1).CreatedAt = 10.January(2010); + blogs[1].Owner!.UserName = "Smith"; + blogs[1].Owner!.Posts = _fakers.BlogPost.Generate(2); + blogs[1].Owner!.Posts[0].Caption = "One"; + blogs[1].Owner!.Posts[1].Caption = "Two"; + blogs[1].Owner!.Posts[1].Comments = _fakers.Comment.Generate(2).ToHashSet(); + blogs[1].Owner!.Posts[1].Comments.ElementAt(0).CreatedAt = 1.January(2000); + blogs[1].Owner!.Posts[1].Comments.ElementAt(1).CreatedAt = 10.January(2010); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -538,13 +538,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Included.ShouldHaveCount(3); responseDocument.Included[0].Type.Should().Be("webAccounts"); - responseDocument.Included[0].Id.Should().Be(blogs[1].Owner.StringId); + responseDocument.Included[0].Id.Should().Be(blogs[1].Owner!.StringId); responseDocument.Included[1].Type.Should().Be("blogPosts"); - responseDocument.Included[1].Id.Should().Be(blogs[1].Owner.Posts[1].StringId); + responseDocument.Included[1].Id.Should().Be(blogs[1].Owner!.Posts[1].StringId); responseDocument.Included[2].Type.Should().Be("comments"); - responseDocument.Included[2].Id.Should().Be(blogs[1].Owner.Posts[1].Comments.ElementAt(1).StringId); + responseDocument.Included[2].Id.Should().Be(blogs[1].Owner!.Posts[1].Comments.ElementAt(1).StringId); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs index 5d0e8b2a88..85138d939a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs @@ -416,10 +416,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Included[2].Id.Should().Be(comment.Parent.Comments.ElementAt(0).StringId); responseDocument.Included[2].Attributes.ShouldContainKey("text").With(value => value.Should().Be(comment.Parent.Comments.ElementAt(0).Text)); - string userName = comment.Parent.Comments.ElementAt(0).Author.UserName; + string userName = comment.Parent.Comments.ElementAt(0).Author!.UserName; responseDocument.Included[3].Type.Should().Be("webAccounts"); - responseDocument.Included[3].Id.Should().Be(comment.Parent.Comments.ElementAt(0).Author.StringId); + responseDocument.Included[3].Id.Should().Be(comment.Parent.Comments.ElementAt(0).Author!.StringId); responseDocument.Included[3].Attributes.ShouldContainKey("userName").With(value => value.Should().Be(userName)); responseDocument.Included[4].Type.Should().Be("comments"); @@ -437,7 +437,7 @@ public async Task Can_include_chain_of_relationships_with_multiple_paths() blog.Posts[0].Author!.Preferences = _fakers.AccountPreferences.Generate(); blog.Posts[0].Comments = _fakers.Comment.Generate(2).ToHashSet(); blog.Posts[0].Comments.ElementAt(0).Author = _fakers.WebAccount.Generate(); - blog.Posts[0].Comments.ElementAt(0).Author.Posts = _fakers.BlogPost.Generate(1); + blog.Posts[0].Comments.ElementAt(0).Author!.Posts = _fakers.BlogPost.Generate(1); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -493,7 +493,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => value.ShouldNotBeNull(); value.Data.SingleValue.ShouldNotBeNull(); value.Data.SingleValue.Type.Should().Be("accountPreferences"); - value.Data.SingleValue.Id.Should().Be(blog.Posts[0].Author!.Preferences.StringId); + value.Data.SingleValue.Id.Should().Be(blog.Posts[0].Author!.Preferences!.StringId); }); responseDocument.Included[1].Relationships.ShouldContainKey("posts").With(value => @@ -503,7 +503,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); responseDocument.Included[2].Type.Should().Be("accountPreferences"); - responseDocument.Included[2].Id.Should().Be(blog.Posts[0].Author!.Preferences.StringId); + responseDocument.Included[2].Id.Should().Be(blog.Posts[0].Author!.Preferences!.StringId); responseDocument.Included[3].Type.Should().Be("comments"); responseDocument.Included[3].Id.Should().Be(blog.Posts[0].Comments.ElementAt(0).StringId); @@ -513,18 +513,18 @@ await _testContext.RunOnDatabaseAsync(async dbContext => value.ShouldNotBeNull(); value.Data.SingleValue.ShouldNotBeNull(); value.Data.SingleValue.Type.Should().Be("webAccounts"); - value.Data.SingleValue.Id.Should().Be(blog.Posts[0].Comments.ElementAt(0).Author.StringId); + value.Data.SingleValue.Id.Should().Be(blog.Posts[0].Comments.ElementAt(0).Author!.StringId); }); responseDocument.Included[4].Type.Should().Be("webAccounts"); - responseDocument.Included[4].Id.Should().Be(blog.Posts[0].Comments.ElementAt(0).Author.StringId); + responseDocument.Included[4].Id.Should().Be(blog.Posts[0].Comments.ElementAt(0).Author!.StringId); responseDocument.Included[4].Relationships.ShouldContainKey("posts").With(value => { value.ShouldNotBeNull(); value.Data.ManyValue.ShouldNotBeEmpty(); value.Data.ManyValue[0].Type.Should().Be("blogPosts"); - value.Data.ManyValue[0].Id.Should().Be(blog.Posts[0].Comments.ElementAt(0).Author.Posts[0].StringId); + value.Data.ManyValue[0].Id.Should().Be(blog.Posts[0].Comments.ElementAt(0).Author!.Posts[0].StringId); }); responseDocument.Included[4].Relationships.ShouldContainKey("preferences").With(value => @@ -534,7 +534,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); responseDocument.Included[5].Type.Should().Be("blogPosts"); - responseDocument.Included[5].Id.Should().Be(blog.Posts[0].Comments.ElementAt(0).Author.Posts[0].StringId); + responseDocument.Included[5].Id.Should().Be(blog.Posts[0].Comments.ElementAt(0).Author!.Posts[0].StringId); responseDocument.Included[5].Relationships.ShouldContainKey("author").With(value => { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs index e9f74f955a..11e7f96806 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs @@ -416,8 +416,8 @@ public async Task Can_paginate_in_multiple_scopes() // Arrange List blogs = _fakers.Blog.Generate(2); blogs[1].Owner = _fakers.WebAccount.Generate(); - blogs[1].Owner.Posts = _fakers.BlogPost.Generate(2); - blogs[1].Owner.Posts[1].Comments = _fakers.Comment.Generate(2).ToHashSet(); + blogs[1].Owner!.Posts = _fakers.BlogPost.Generate(2); + blogs[1].Owner!.Posts[1].Comments = _fakers.Comment.Generate(2).ToHashSet(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -441,13 +441,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Included.ShouldHaveCount(3); responseDocument.Included[0].Type.Should().Be("webAccounts"); - responseDocument.Included[0].Id.Should().Be(blogs[1].Owner.StringId); + responseDocument.Included[0].Id.Should().Be(blogs[1].Owner!.StringId); responseDocument.Included[1].Type.Should().Be("blogPosts"); - responseDocument.Included[1].Id.Should().Be(blogs[1].Owner.Posts[1].StringId); + responseDocument.Included[1].Id.Should().Be(blogs[1].Owner!.Posts[1].StringId); responseDocument.Included[2].Type.Should().Be("comments"); - responseDocument.Included[2].Id.Should().Be(blogs[1].Owner.Posts[1].Comments.ElementAt(1).StringId); + responseDocument.Included[2].Id.Should().Be(blogs[1].Owner!.Posts[1].Comments.ElementAt(1).StringId); string linkPrefix = $"{HostPrefix}/blogs?include=owner.posts.comments"; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Sorting/SortTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Sorting/SortTests.cs index f01c7f51bf..404342a554 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Sorting/SortTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Sorting/SortTests.cs @@ -407,13 +407,13 @@ public async Task Can_sort_in_multiple_scopes() blogs[1].Title = "Technology"; blogs[1].Owner = _fakers.WebAccount.Generate(); - blogs[1].Owner.Posts = _fakers.BlogPost.Generate(2); - blogs[1].Owner.Posts[0].Caption = "One"; - blogs[1].Owner.Posts[1].Caption = "Two"; + blogs[1].Owner!.Posts = _fakers.BlogPost.Generate(2); + blogs[1].Owner!.Posts[0].Caption = "One"; + blogs[1].Owner!.Posts[1].Caption = "Two"; - blogs[1].Owner.Posts[1].Comments = _fakers.Comment.Generate(2).ToHashSet(); - blogs[1].Owner.Posts[1].Comments.ElementAt(0).CreatedAt = 1.January(2000); - blogs[1].Owner.Posts[1].Comments.ElementAt(0).CreatedAt = 10.January(2010); + blogs[1].Owner!.Posts[1].Comments = _fakers.Comment.Generate(2).ToHashSet(); + blogs[1].Owner!.Posts[1].Comments.ElementAt(0).CreatedAt = 1.January(2000); + blogs[1].Owner!.Posts[1].Comments.ElementAt(0).CreatedAt = 10.January(2010); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -437,19 +437,19 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Included.ShouldHaveCount(5); responseDocument.Included[0].Type.Should().Be("webAccounts"); - responseDocument.Included[0].Id.Should().Be(blogs[1].Owner.StringId); + responseDocument.Included[0].Id.Should().Be(blogs[1].Owner!.StringId); responseDocument.Included[1].Type.Should().Be("blogPosts"); - responseDocument.Included[1].Id.Should().Be(blogs[1].Owner.Posts[1].StringId); + responseDocument.Included[1].Id.Should().Be(blogs[1].Owner!.Posts[1].StringId); responseDocument.Included[2].Type.Should().Be("comments"); - responseDocument.Included[2].Id.Should().Be(blogs[1].Owner.Posts[1].Comments.ElementAt(1).StringId); + responseDocument.Included[2].Id.Should().Be(blogs[1].Owner!.Posts[1].Comments.ElementAt(1).StringId); responseDocument.Included[3].Type.Should().Be("comments"); - responseDocument.Included[3].Id.Should().Be(blogs[1].Owner.Posts[1].Comments.ElementAt(0).StringId); + responseDocument.Included[3].Id.Should().Be(blogs[1].Owner!.Posts[1].Comments.ElementAt(0).StringId); responseDocument.Included[4].Type.Should().Be("blogPosts"); - responseDocument.Included[4].Id.Should().Be(blogs[1].Owner.Posts[0].StringId); + responseDocument.Included[4].Id.Should().Be(blogs[1].Owner!.Posts[0].StringId); } [Fact] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs index adb9e5807d..a6da424bee 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs @@ -540,7 +540,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => blogCaptured.Title.Should().Be(blog.Title); blogCaptured.PlatformName.Should().BeNull(); - blogCaptured.Owner.UserName.Should().Be(blog.Owner.UserName); + blogCaptured.Owner!.UserName.Should().Be(blog.Owner.UserName); blogCaptured.Owner.DisplayName.Should().Be(blog.Owner.DisplayName); blogCaptured.Owner.DateOfBirth.Should().BeNull(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/WebAccount.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/WebAccount.cs index 90b30efa7f..21060d0eef 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/WebAccount.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/WebAccount.cs @@ -28,7 +28,7 @@ public sealed class WebAccount : Identifiable public IList Posts { get; set; } = new List(); [HasOne] - public AccountPreferences Preferences { get; set; } = null!; + public AccountPreferences? Preferences { get; set; } [HasMany] public IList LoginAttempts { get; set; } = new List(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Serialization/Scholarship.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Serialization/Scholarship.cs index 68afebe251..60a0bb0170 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Serialization/Scholarship.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Serialization/Scholarship.cs @@ -18,6 +18,6 @@ public sealed class Scholarship : Identifiable public IList Participants { get; set; } = new List(); [HasOne] - public Student PrimaryContact { get; set; } = null!; + public Student? PrimaryContact { get; set; } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/Bed.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/Bed.cs index 5c229c796d..d197b4337e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/Bed.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/Bed.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; @@ -9,5 +10,11 @@ public sealed class Bed : Identifiable { [Attr] public bool IsDouble { get; set; } + + [HasMany] + public IList Pillows { get; set; } = new List(); + + [HasOne] + public Room? Room { get; set; } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/BedsReadOnlyController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/BedsReadOnlyController.cs new file mode 100644 index 0000000000..0b0aa04d45 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/BedsReadOnlyController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreTests.IntegrationTests.RestrictedControllers +{ + public sealed class BedsReadOnlyController : JsonApiQueryController + { + public BedsReadOnlyController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceQueryService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/BlockingHttpPatchController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/BlockingHttpPatchController.cs deleted file mode 100644 index d09e5ddffa..0000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/BlockingHttpPatchController.cs +++ /dev/null @@ -1,18 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Controllers.Annotations; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreTests.IntegrationTests.RestrictedControllers -{ - [NoHttpPatch] - public sealed class BlockingHttpPatchController : JsonApiController - { - public BlockingHttpPatchController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, - IResourceService resourceService) - : base(options, resourceGraph, loggerFactory, resourceService) - { - } - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/BlockingHttpPostController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/BlockingHttpPostController.cs deleted file mode 100644 index 1f964e20b7..0000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/BlockingHttpPostController.cs +++ /dev/null @@ -1,18 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Controllers.Annotations; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreTests.IntegrationTests.RestrictedControllers -{ - [NoHttpPost] - public sealed class BlockingHttpPostController : JsonApiController - { - public BlockingHttpPostController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, - IResourceService resourceService) - : base(options, resourceGraph, loggerFactory, resourceService) - { - } - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/Chair.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/Chair.cs index 62e2358d1b..4e4a337b5b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/Chair.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/Chair.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; @@ -9,5 +10,11 @@ public sealed class Chair : Identifiable { [Attr] public int LegCount { get; set; } + + [HasMany] + public IList Pillows { get; set; } = new List(); + + [HasOne] + public Room? Room { get; set; } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/ChairsNoRelationshipsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/ChairsNoRelationshipsController.cs new file mode 100644 index 0000000000..5b4bb83f3f --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/ChairsNoRelationshipsController.cs @@ -0,0 +1,17 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreTests.IntegrationTests.RestrictedControllers +{ + public sealed class ChairsNoRelationshipsController : JsonApiController + { + public ChairsNoRelationshipsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IGetAllService? getAll, IGetByIdService? getById, ICreateService? create, IUpdateService? update, + IDeleteService? delete) + : base(options, resourceGraph, loggerFactory, getAll, getById, null, null, create, null, update, null, delete) + { + } + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/DisableQueryStringTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/DisableQueryStringTests.cs index 3c367218e9..003a481928 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/DisableQueryStringTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/DisableQueryStringTests.cs @@ -18,8 +18,8 @@ public DisableQueryStringTests(IntegrationTestContext(); - testContext.UseController(); + testContext.UseController(); + testContext.UseController(); testContext.ConfigureServicesAfterStartup(services => { @@ -72,11 +72,26 @@ public async Task Cannot_paginate_if_query_string_parameter_is_blocked_by_contro error.Source.Parameter.Should().Be("page[number]"); } + [Fact] + public async Task Can_use_custom_query_string_parameter() + { + // Arrange + const string route = "/sofas?skipCache"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ShouldNotBeEmpty(); + } + [Fact] public async Task Cannot_use_custom_query_string_parameter_if_blocked_by_controller() { // Arrange - const string route = "/beds?skipCache=true"; + const string route = "/pillows?skipCache"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/HttpReadOnlyTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/HttpReadOnlyTests.cs deleted file mode 100644 index 3874d6dd96..0000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/HttpReadOnlyTests.cs +++ /dev/null @@ -1,135 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreTests.IntegrationTests.RestrictedControllers -{ - public sealed class HttpReadOnlyTests : IClassFixture, RestrictionDbContext>> - { - private readonly IntegrationTestContext, RestrictionDbContext> _testContext; - private readonly RestrictionFakers _fakers = new(); - - public HttpReadOnlyTests(IntegrationTestContext, RestrictionDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - } - - [Fact] - public async Task Can_get_resources() - { - // Arrange - const string route = "/beds"; - - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - } - - [Fact] - public async Task Cannot_create_resource() - { - // Arrange - var requestBody = new - { - data = new - { - type = "beds", - attributes = new - { - } - } - }; - - const string route = "/beds"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.MethodNotAllowed); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.MethodNotAllowed); - error.Title.Should().Be("The request method is not allowed."); - error.Detail.Should().Be("Endpoint does not support POST requests."); - } - - [Fact] - public async Task Cannot_update_resource() - { - // Arrange - Bed existingBed = _fakers.Bed.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Beds.Add(existingBed); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "beds", - id = existingBed.StringId, - attributes = new - { - } - } - }; - - string route = $"/beds/{existingBed.StringId}"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.MethodNotAllowed); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.MethodNotAllowed); - error.Title.Should().Be("The request method is not allowed."); - error.Detail.Should().Be("Endpoint does not support PATCH requests."); - } - - [Fact] - public async Task Cannot_delete_resource() - { - // Arrange - Bed existingBed = _fakers.Bed.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Beds.Add(existingBed); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/beds/{existingBed.StringId}"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteDeleteAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.MethodNotAllowed); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.MethodNotAllowed); - error.Title.Should().Be("The request method is not allowed."); - error.Detail.Should().Be("Endpoint does not support DELETE requests."); - } - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/NoHttpDeleteTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/NoHttpDeleteTests.cs deleted file mode 100644 index e51f6d0c38..0000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/NoHttpDeleteTests.cs +++ /dev/null @@ -1,121 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreTests.IntegrationTests.RestrictedControllers -{ - public sealed class NoHttpDeleteTests : IClassFixture, RestrictionDbContext>> - { - private readonly IntegrationTestContext, RestrictionDbContext> _testContext; - private readonly RestrictionFakers _fakers = new(); - - public NoHttpDeleteTests(IntegrationTestContext, RestrictionDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - } - - [Fact] - public async Task Can_get_resources() - { - // Arrange - const string route = "/sofas"; - - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - } - - [Fact] - public async Task Can_create_resource() - { - // Arrange - var requestBody = new - { - data = new - { - type = "sofas", - attributes = new - { - } - } - }; - - const string route = "/sofas"; - - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - } - - [Fact] - public async Task Can_update_resource() - { - // Arrange - Sofa existingSofa = _fakers.Sofa.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Sofas.Add(existingSofa); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "sofas", - id = existingSofa.StringId, - attributes = new - { - } - } - }; - - string route = $"/sofas/{existingSofa.StringId}"; - - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - } - - [Fact] - public async Task Cannot_delete_resource() - { - // Arrange - Sofa existingSofa = _fakers.Sofa.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Sofas.Add(existingSofa); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/sofas/{existingSofa.StringId}"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteDeleteAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.MethodNotAllowed); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.MethodNotAllowed); - error.Title.Should().Be("The request method is not allowed."); - error.Detail.Should().Be("Endpoint does not support DELETE requests."); - } - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/NoHttpPatchTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/NoHttpPatchTests.cs deleted file mode 100644 index cb6361faf1..0000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/NoHttpPatchTests.cs +++ /dev/null @@ -1,121 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreTests.IntegrationTests.RestrictedControllers -{ - public sealed class NoHttpPatchTests : IClassFixture, RestrictionDbContext>> - { - private readonly IntegrationTestContext, RestrictionDbContext> _testContext; - private readonly RestrictionFakers _fakers = new(); - - public NoHttpPatchTests(IntegrationTestContext, RestrictionDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - } - - [Fact] - public async Task Can_get_resources() - { - // Arrange - const string route = "/chairs"; - - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - } - - [Fact] - public async Task Can_create_resource() - { - // Arrange - var requestBody = new - { - data = new - { - type = "chairs", - attributes = new - { - } - } - }; - - const string route = "/chairs"; - - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - } - - [Fact] - public async Task Cannot_update_resource() - { - // Arrange - Chair existingChair = _fakers.Chair.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Chairs.Add(existingChair); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "chairs", - id = existingChair.StringId, - attributes = new - { - } - } - }; - - string route = $"/chairs/{existingChair.StringId}"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.MethodNotAllowed); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.MethodNotAllowed); - error.Title.Should().Be("The request method is not allowed."); - error.Detail.Should().Be("Endpoint does not support PATCH requests."); - } - - [Fact] - public async Task Can_delete_resource() - { - // Arrange - Chair existingChair = _fakers.Chair.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Chairs.Add(existingChair); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/chairs/{existingChair.StringId}"; - - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteDeleteAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - } - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/NoHttpPostTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/NoHttpPostTests.cs deleted file mode 100644 index 65106dad35..0000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/NoHttpPostTests.cs +++ /dev/null @@ -1,121 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreTests.IntegrationTests.RestrictedControllers -{ - public sealed class NoHttpPostTests : IClassFixture, RestrictionDbContext>> - { - private readonly IntegrationTestContext, RestrictionDbContext> _testContext; - private readonly RestrictionFakers _fakers = new(); - - public NoHttpPostTests(IntegrationTestContext, RestrictionDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - } - - [Fact] - public async Task Can_get_resources() - { - // Arrange - const string route = "/tables"; - - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - } - - [Fact] - public async Task Cannot_create_resource() - { - // Arrange - var requestBody = new - { - data = new - { - type = "tables", - attributes = new - { - } - } - }; - - const string route = "/tables"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.MethodNotAllowed); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.MethodNotAllowed); - error.Title.Should().Be("The request method is not allowed."); - error.Detail.Should().Be("Endpoint does not support POST requests."); - } - - [Fact] - public async Task Can_update_resource() - { - // Arrange - Table existingTable = _fakers.Table.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Tables.Add(existingTable); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "tables", - id = existingTable.StringId, - attributes = new - { - } - } - }; - - string route = $"/tables/{existingTable.StringId}"; - - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - } - - [Fact] - public async Task Can_delete_resource() - { - // Arrange - Table existingTable = _fakers.Table.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Tables.Add(existingTable); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/tables/{existingTable.StringId}"; - - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteDeleteAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - } - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/NoRelationshipsControllerTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/NoRelationshipsControllerTests.cs new file mode 100644 index 0000000000..191238463f --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/NoRelationshipsControllerTests.cs @@ -0,0 +1,319 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.RestrictedControllers +{ + public sealed class NoRelationshipsControllerTests : IClassFixture, RestrictionDbContext>> + { + private readonly IntegrationTestContext, RestrictionDbContext> _testContext; + private readonly RestrictionFakers _fakers = new(); + + public NoRelationshipsControllerTests(IntegrationTestContext, RestrictionDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + } + + [Fact] + public async Task Can_get_resources() + { + // Arrange + const string route = "/chairs"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } + + [Fact] + public async Task Can_get_resource() + { + // Arrange + Chair chair = _fakers.Chair.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Chairs.Add(chair); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/chairs/{chair.StringId}"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } + + [Fact] + public async Task Cannot_get_secondary_resources() + { + // Arrange + Chair chair = _fakers.Chair.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Chairs.Add(chair); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/chairs/{chair.StringId}/pillows"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("The requested endpoint is not accessible."); + error.Detail.Should().Be($"Endpoint '{route}' is not accessible for GET requests."); + } + + [Fact] + public async Task Cannot_get_secondary_resource() + { + // Arrange + Chair chair = _fakers.Chair.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Chairs.Add(chair); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/chairs/{chair.StringId}/room"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("The requested endpoint is not accessible."); + error.Detail.Should().Be($"Endpoint '{route}' is not accessible for GET requests."); + } + + [Fact] + public async Task Cannot_get_relationship() + { + // Arrange + Chair chair = _fakers.Chair.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Chairs.Add(chair); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/chairs/{chair.StringId}/relationships/pillows"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("The requested endpoint is not accessible."); + error.Detail.Should().Be($"Endpoint '{route}' is not accessible for GET requests."); + } + + [Fact] + public async Task Can_create_resource() + { + // Arrange + var requestBody = new + { + data = new + { + type = "chairs", + attributes = new + { + } + } + }; + + const string route = "/chairs"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + } + + [Fact] + public async Task Can_update_resource() + { + // Arrange + Chair existingChair = _fakers.Chair.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Chairs.Add(existingChair); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "chairs", + id = existingChair.StringId, + attributes = new + { + } + } + }; + + string route = $"/chairs/{existingChair.StringId}"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + } + + [Fact] + public async Task Can_delete_resource() + { + // Arrange + Chair existingChair = _fakers.Chair.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Chairs.Add(existingChair); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/chairs/{existingChair.StringId}"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + } + + [Fact] + public async Task Cannot_update_relationship() + { + // Arrange + Chair existingChair = _fakers.Chair.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Chairs.Add(existingChair); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = (object?)null + }; + + string route = $"/chairs/{existingChair.StringId}/relationships/room"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("The requested endpoint is not accessible."); + error.Detail.Should().Be($"Endpoint '{route}' is not accessible for PATCH requests."); + } + + [Fact] + public async Task Cannot_add_to_ToMany_relationship() + { + // Arrange + Chair existingChair = _fakers.Chair.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Chairs.Add(existingChair); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = Array.Empty() + }; + + string route = $"/chairs/{existingChair.StringId}/relationships/pillows"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("The requested endpoint is not accessible."); + error.Detail.Should().Be($"Endpoint '{route}' is not accessible for POST requests."); + } + + [Fact] + public async Task Cannot_remove_from_ToMany_relationship() + { + // Arrange + Chair existingChair = _fakers.Chair.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Chairs.Add(existingChair); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = Array.Empty() + }; + + string route = $"/chairs/{existingChair.StringId}/relationships/pillows"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("The requested endpoint is not accessible."); + error.Detail.Should().Be($"Endpoint '{route}' is not accessible for DELETE requests."); + } + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/Pillow.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/Pillow.cs new file mode 100644 index 0000000000..cf3642aeaf --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/Pillow.cs @@ -0,0 +1,13 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.RestrictedControllers +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class Pillow : Identifiable + { + [Attr] + public string Color { get; set; } = null!; + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/BlockingWritesController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/PillowsNoSkipCacheController.cs similarity index 59% rename from test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/BlockingWritesController.cs rename to test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/PillowsNoSkipCacheController.cs index fece61abcd..2ca39b0072 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/BlockingWritesController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/PillowsNoSkipCacheController.cs @@ -6,12 +6,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.RestrictedControllers { - [HttpReadOnly] [DisableQueryString("skipCache")] - public sealed class BlockingWritesController : JsonApiController + public sealed class PillowsNoSkipCacheController : JsonApiController { - public BlockingWritesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, - IResourceService resourceService) + public PillowsNoSkipCacheController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) : base(options, resourceGraph, loggerFactory, resourceService) { } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/ReadOnlyControllerTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/ReadOnlyControllerTests.cs new file mode 100644 index 0000000000..d171982473 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/ReadOnlyControllerTests.cs @@ -0,0 +1,319 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.RestrictedControllers +{ + public sealed class ReadOnlyControllerTests : IClassFixture, RestrictionDbContext>> + { + private readonly IntegrationTestContext, RestrictionDbContext> _testContext; + private readonly RestrictionFakers _fakers = new(); + + public ReadOnlyControllerTests(IntegrationTestContext, RestrictionDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + } + + [Fact] + public async Task Can_get_resources() + { + // Arrange + const string route = "/beds"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } + + [Fact] + public async Task Can_get_resource() + { + // Arrange + Bed bed = _fakers.Bed.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Beds.Add(bed); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/beds/{bed.StringId}"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } + + [Fact] + public async Task Can_get_secondary_resources() + { + // Arrange + Bed bed = _fakers.Bed.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Beds.Add(bed); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/beds/{bed.StringId}/pillows"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } + + [Fact] + public async Task Can_get_secondary_resource() + { + // Arrange + Bed bed = _fakers.Bed.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Beds.Add(bed); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/beds/{bed.StringId}/room"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } + + [Fact] + public async Task Can_get_relationship() + { + // Arrange + Bed bed = _fakers.Bed.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Beds.Add(bed); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/beds/{bed.StringId}/relationships/pillows"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } + + [Fact] + public async Task Cannot_create_resource() + { + // Arrange + var requestBody = new + { + data = new + { + type = "beds", + attributes = new + { + } + } + }; + + const string route = "/beds?include=pillows"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("The requested endpoint is not accessible."); + error.Detail.Should().Be("Endpoint '/beds' is not accessible for POST requests."); + } + + [Fact] + public async Task Cannot_update_resource() + { + // Arrange + Bed existingBed = _fakers.Bed.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Beds.Add(existingBed); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "beds", + id = existingBed.StringId, + attributes = new + { + } + } + }; + + string route = $"/beds/{existingBed.StringId}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("The requested endpoint is not accessible."); + error.Detail.Should().Be($"Endpoint '{route}' is not accessible for PATCH requests."); + } + + [Fact] + public async Task Cannot_delete_resource() + { + // Arrange + Bed existingBed = _fakers.Bed.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Beds.Add(existingBed); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/beds/{existingBed.StringId}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("The requested endpoint is not accessible."); + error.Detail.Should().Be($"Endpoint '{route}' is not accessible for DELETE requests."); + } + + [Fact] + public async Task Cannot_update_relationship() + { + // Arrange + Bed existingBed = _fakers.Bed.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Beds.Add(existingBed); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = (object?)null + }; + + string route = $"/beds/{existingBed.StringId}/relationships/room"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("The requested endpoint is not accessible."); + error.Detail.Should().Be($"Endpoint '{route}' is not accessible for PATCH requests."); + } + + [Fact] + public async Task Cannot_add_to_ToMany_relationship() + { + // Arrange + Bed existingBed = _fakers.Bed.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Beds.Add(existingBed); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = Array.Empty() + }; + + string route = $"/beds/{existingBed.StringId}/relationships/pillows"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("The requested endpoint is not accessible."); + error.Detail.Should().Be($"Endpoint '{route}' is not accessible for POST requests."); + } + + [Fact] + public async Task Cannot_remove_from_ToMany_relationship() + { + // Arrange + Bed existingBed = _fakers.Bed.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Beds.Add(existingBed); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = Array.Empty() + }; + + string route = $"/beds/{existingBed.StringId}/relationships/pillows"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("The requested endpoint is not accessible."); + error.Detail.Should().Be($"Endpoint '{route}' is not accessible for DELETE requests."); + } + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/RestrictionFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/RestrictionFakers.cs index 63eb274164..d7d8b884de 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/RestrictionFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/RestrictionFakers.cs @@ -1,5 +1,6 @@ using System; using Bogus; +using JetBrains.Annotations; using TestBuildingBlocks; // @formatter:wrap_chained_method_calls chop_always @@ -7,8 +8,14 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.RestrictedControllers { + [UsedImplicitly(ImplicitUseTargetFlags.Members)] internal sealed class RestrictionFakers : FakerContainer { + private readonly Lazy> _lazyRoomFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(room => room.WindowCount, faker => faker.Random.Int(0, 3))); + private readonly Lazy> _lazyTableFaker = new(() => new Faker() .UseSeed(GetFakerSeed()) @@ -24,14 +31,21 @@ internal sealed class RestrictionFakers : FakerContainer .UseSeed(GetFakerSeed()) .RuleFor(sofa => sofa.SeatCount, faker => faker.Random.Int(2, 6))); + private readonly Lazy> _lazyPillowFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(pillow => pillow.Color, faker => faker.Internet.Color())); + private readonly Lazy> _lazyBedFaker = new(() => new Faker() .UseSeed(GetFakerSeed()) .RuleFor(bed => bed.IsDouble, faker => faker.Random.Bool())); + public Faker Room => _lazyRoomFaker.Value; public Faker
Table => _lazyTableFaker.Value; public Faker Chair => _lazyChairFaker.Value; public Faker Sofa => _lazySofaFaker.Value; public Faker Bed => _lazyBedFaker.Value; + public Faker Pillow => _lazyPillowFaker.Value; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/Room.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/Room.cs new file mode 100644 index 0000000000..74714d0d01 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/Room.cs @@ -0,0 +1,13 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.RestrictedControllers +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class Room : Identifiable + { + [Attr] + public int WindowCount { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/SkipCacheQueryStringParameterReader.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/SkipCacheQueryStringParameterReader.cs index 43fe12f42a..ec8e731983 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/SkipCacheQueryStringParameterReader.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/SkipCacheQueryStringParameterReader.cs @@ -1,6 +1,5 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Controllers.Annotations; -using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.QueryStrings; using Microsoft.Extensions.Primitives; @@ -13,6 +12,8 @@ public sealed class SkipCacheQueryStringParameterReader : IQueryStringParameterR [UsedImplicitly] public bool SkipCache { get; private set; } + public bool AllowEmptyValue => true; + public bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttribute) { return !disableQueryStringAttribute.ParameterNames.Contains(SkipCacheParameterName); @@ -25,13 +26,7 @@ public bool CanRead(string parameterName) public void Read(string parameterName, StringValues parameterValue) { - if (!bool.TryParse(parameterValue, out bool skipCache)) - { - throw new InvalidQueryStringParameterException(parameterName, "Boolean value required.", - $"The value '{parameterValue}' is not a valid boolean."); - } - - SkipCache = skipCache; + SkipCache = true; } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/BlockingHttpDeleteController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/SofasBlockingSortPageController.cs similarity index 71% rename from test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/BlockingHttpDeleteController.cs rename to test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/SofasBlockingSortPageController.cs index 060c3df6e3..7f300a3e0a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/BlockingHttpDeleteController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/SofasBlockingSortPageController.cs @@ -7,11 +7,10 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.RestrictedControllers { - [NoHttpDelete] [DisableQueryString(JsonApiQueryStringParameters.Sort | JsonApiQueryStringParameters.Page)] - public sealed class BlockingHttpDeleteController : JsonApiController + public sealed class SofasBlockingSortPageController : JsonApiController { - public BlockingHttpDeleteController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + public SofasBlockingSortPageController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IResourceService resourceService) : base(options, resourceGraph, loggerFactory, resourceService) { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/Table.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/Table.cs index a2ed9e0734..c0df183f38 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/Table.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/Table.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; @@ -9,5 +10,11 @@ public sealed class Table : Identifiable { [Attr] public int LegCount { get; set; } + + [HasMany] + public IList Chairs { get; set; } = new List(); + + [HasOne] + public Room? Room { get; set; } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/TablesWriteOnlyController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/TablesWriteOnlyController.cs new file mode 100644 index 0000000000..484e8fc13a --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/TablesWriteOnlyController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreTests.IntegrationTests.RestrictedControllers +{ + public sealed class TablesWriteOnlyController : JsonApiCommandController + { + public TablesWriteOnlyController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceCommandService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/WriteOnlyControllerTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/WriteOnlyControllerTests.cs new file mode 100644 index 0000000000..7008eafd9a --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/WriteOnlyControllerTests.cs @@ -0,0 +1,312 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.RestrictedControllers +{ + public sealed class WriteOnlyControllerTests : IClassFixture, RestrictionDbContext>> + { + private readonly IntegrationTestContext, RestrictionDbContext> _testContext; + private readonly RestrictionFakers _fakers = new(); + + public WriteOnlyControllerTests(IntegrationTestContext, RestrictionDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + } + + [Fact] + public async Task Cannot_get_resources() + { + // Arrange + const string route = "/tables?fields[tables]=legCount"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("The requested endpoint is not accessible."); + error.Detail.Should().Be("Endpoint '/tables' is not accessible for GET requests."); + } + + [Fact] + public async Task Cannot_get_resource() + { + // Arrange + Table table = _fakers.Table.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Tables.Add(table); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/tables/{table.StringId}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("The requested endpoint is not accessible."); + error.Detail.Should().Be($"Endpoint '{route}' is not accessible for GET requests."); + } + + [Fact] + public async Task Cannot_get_secondary_resources() + { + // Arrange + Table table = _fakers.Table.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Tables.Add(table); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/tables/{table.StringId}/chairs"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("The requested endpoint is not accessible."); + error.Detail.Should().Be($"Endpoint '{route}' is not accessible for GET requests."); + } + + [Fact] + public async Task Cannot_get_secondary_resource() + { + // Arrange + Table table = _fakers.Table.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Tables.Add(table); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/tables/{table.StringId}/room"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("The requested endpoint is not accessible."); + error.Detail.Should().Be($"Endpoint '{route}' is not accessible for GET requests."); + } + + [Fact] + public async Task Cannot_get_relationship() + { + // Arrange + Table table = _fakers.Table.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Tables.Add(table); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/tables/{table.StringId}/relationships/chairs"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("The requested endpoint is not accessible."); + error.Detail.Should().Be($"Endpoint '{route}' is not accessible for GET requests."); + } + + [Fact] + public async Task Can_create_resource() + { + // Arrange + var requestBody = new + { + data = new + { + type = "tables", + attributes = new + { + } + } + }; + + const string route = "/tables"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + } + + [Fact] + public async Task Can_update_resource() + { + // Arrange + Table existingTable = _fakers.Table.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Tables.Add(existingTable); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "tables", + id = existingTable.StringId, + attributes = new + { + } + } + }; + + string route = $"/tables/{existingTable.StringId}"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + } + + [Fact] + public async Task Can_delete_resource() + { + // Arrange + Table existingTable = _fakers.Table.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Tables.Add(existingTable); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/tables/{existingTable.StringId}"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + } + + [Fact] + public async Task Can_update_relationship() + { + // Arrange + Table existingTable = _fakers.Table.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Tables.Add(existingTable); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = (object?)null + }; + + string route = $"/tables/{existingTable.StringId}/relationships/room"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + } + + [Fact] + public async Task Can_add_to_ToMany_relationship() + { + // Arrange + Table existingTable = _fakers.Table.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Tables.Add(existingTable); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = Array.Empty() + }; + + string route = $"/tables/{existingTable.StringId}/relationships/chairs"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + } + + [Fact] + public async Task Can_remove_from_ToMany_relationship() + { + // Arrange + Table existingTable = _fakers.Table.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Tables.Add(existingTable); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = Array.Empty() + }; + + string route = $"/tables/{existingTable.StringId}/relationships/chairs"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + } + } +} diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs index 15eec6cea2..d834d8e11e 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs @@ -166,7 +166,7 @@ public void Applies_cascading_settings_for_resource_links(LinkTypes linksInResou var linkBuilder = new LinkBuilder(options, request, paginationContext, httpContextAccessor, linkGenerator, controllerResourceMapping); // Act - ResourceLinks? resourceLinks = linkBuilder.GetResourceLinks(exampleResourceType, "id"); + ResourceLinks? resourceLinks = linkBuilder.GetResourceLinks(exampleResourceType, new ExampleResource()); // Assert if (expected == LinkTypes.Self) @@ -331,7 +331,7 @@ public void Applies_cascading_settings_for_relationship_links(LinkTypes linksInR }; // Act - RelationshipLinks? relationshipLinks = linkBuilder.GetRelationshipLinks(relationship, "?"); + RelationshipLinks? relationshipLinks = linkBuilder.GetRelationshipLinks(relationship, new ExampleResource()); // Assert if (expected == LinkTypes.None) @@ -412,7 +412,7 @@ public override string GetUriByAddress(HttpContext httpContext, TAddre return "https://domain.com/some/path"; } - public override string GetUriByAddress(TAddress address, RouteValueDictionary values, string scheme, HostString host, + public override string GetUriByAddress(TAddress address, RouteValueDictionary values, string? scheme, HostString host, PathString pathBase = new(), FragmentString fragment = new(), LinkOptions? options = null) { throw new NotImplementedException(); diff --git a/test/JsonApiDotNetCoreTests/UnitTests/ModelStateValidation/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/ModelStateValidation/ModelStateValidationTests.cs index a6dafd3b35..efb63a856b 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/ModelStateValidation/ModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/ModelStateValidation/ModelStateValidationTests.cs @@ -39,7 +39,7 @@ public void Renders_JSON_path_for_ModelState_key_in_resource_request(string mode { // Arrange var options = new JsonApiOptions(); - IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add().Add().Build(); + IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add().Add().Build(); var modelState = new ModelStateDictionary(); modelState.AddModelError(modelStateKey, "(ignored error message)"); @@ -87,7 +87,7 @@ public void Renders_JSON_path_for_ModelState_key_in_operations_request(string mo { // Arrange var options = new JsonApiOptions(); - IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add().Add().Build(); + IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add().Add().Build(); var modelState = new ModelStateDictionary(); modelState.AddModelError(modelStateKey, "(ignored error message)"); diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/BaseParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/BaseParseTests.cs index 193a5c1ea9..c0929a162f 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/BaseParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/BaseParseTests.cs @@ -19,13 +19,13 @@ protected BaseParseTests() // @formatter:keep_existing_linebreaks true ResourceGraph = new ResourceGraphBuilder(Options, NullLoggerFactory.Instance) - .Add() - .Add() - .Add