diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index d77e271e4d..40d3ba4893 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "jetbrains.resharper.globaltools": { - "version": "2022.2.4", + "version": "2023.1.0", "commands": [ "jb" ] @@ -21,13 +21,13 @@ ] }, "dotnet-reportgenerator-globaltool": { - "version": "5.1.15", + "version": "5.1.19", "commands": [ "reportgenerator" ] }, "docfx": { - "version": "2.60.2", + "version": "2.62.2", "commands": [ "docfx" ] diff --git a/.editorconfig b/.editorconfig index ca191cf90e..86cbbc3700 100644 --- a/.editorconfig +++ b/.editorconfig @@ -66,15 +66,18 @@ csharp_indent_case_contents_when_block = false csharp_preserve_single_line_statements = false # 'var' usage preferences -csharp_style_var_for_built_in_types = false:suggestion -csharp_style_var_when_type_is_apparent = true:suggestion -csharp_style_var_elsewhere = false:suggestion +csharp_style_var_for_built_in_types = false:none +csharp_style_var_when_type_is_apparent = true:none +csharp_style_var_elsewhere = false:none # Parentheses preferences dotnet_style_parentheses_in_arithmetic_binary_operators = never_if_unnecessary:suggestion dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:suggestion dotnet_style_parentheses_in_relational_binary_operators = never_if_unnecessary:suggestion +# Expression value is never used +dotnet_diagnostic.IDE0058.severity = none + #### Naming Style #### dotnet_diagnostic.IDE1006.severity = warning diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md index 689f2daa01..51304f7f03 100644 --- a/.github/ISSUE_TEMPLATE/question.md +++ b/.github/ISSUE_TEMPLATE/question.md @@ -7,14 +7,25 @@ assignees: '' --- + + #### SUMMARY - + #### DETAILS - + + #### STEPS TO REPRODUCE - diff --git a/benchmarks/Serialization/OperationsSerializationBenchmarks.cs b/benchmarks/Serialization/OperationsSerializationBenchmarks.cs index 471c9604c7..5bb97a3156 100644 --- a/benchmarks/Serialization/OperationsSerializationBenchmarks.cs +++ b/benchmarks/Serialization/OperationsSerializationBenchmarks.cs @@ -2,6 +2,7 @@ using BenchmarkDotNet.Attributes; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Internal; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; @@ -130,6 +131,6 @@ protected override JsonApiRequest CreateJsonApiRequest(IResourceGraph resourceGr protected override IEvaluatedIncludeCache CreateEvaluatedIncludeCache(IResourceGraph resourceGraph) { - return new EvaluatedIncludeCache(); + return new EvaluatedIncludeCache(Array.Empty()); } } diff --git a/benchmarks/Serialization/ResourceSerializationBenchmarks.cs b/benchmarks/Serialization/ResourceSerializationBenchmarks.cs index a985bd5936..3f9efcc11d 100644 --- a/benchmarks/Serialization/ResourceSerializationBenchmarks.cs +++ b/benchmarks/Serialization/ResourceSerializationBenchmarks.cs @@ -4,6 +4,7 @@ using JsonApiDotNetCore; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Queries.Internal; using JsonApiDotNetCore.Resources.Annotations; @@ -121,12 +122,12 @@ protected override JsonApiRequest CreateJsonApiRequest(IResourceGraph resourceGr protected override IEvaluatedIncludeCache CreateEvaluatedIncludeCache(IResourceGraph resourceGraph) { - ResourceType resourceAType = resourceGraph.GetResourceType(); + ResourceType resourceType = resourceGraph.GetResourceType(); - RelationshipAttribute single2 = resourceAType.GetRelationshipByPropertyName(nameof(OutgoingResource.Single2)); - RelationshipAttribute single3 = resourceAType.GetRelationshipByPropertyName(nameof(OutgoingResource.Single3)); - RelationshipAttribute multi4 = resourceAType.GetRelationshipByPropertyName(nameof(OutgoingResource.Multi4)); - RelationshipAttribute multi5 = resourceAType.GetRelationshipByPropertyName(nameof(OutgoingResource.Multi5)); + RelationshipAttribute single2 = resourceType.GetRelationshipByPropertyName(nameof(OutgoingResource.Single2)); + RelationshipAttribute single3 = resourceType.GetRelationshipByPropertyName(nameof(OutgoingResource.Single3)); + RelationshipAttribute multi4 = resourceType.GetRelationshipByPropertyName(nameof(OutgoingResource.Multi4)); + RelationshipAttribute multi5 = resourceType.GetRelationshipByPropertyName(nameof(OutgoingResource.Multi5)); var include = new IncludeExpression(new HashSet { @@ -142,7 +143,7 @@ protected override IEvaluatedIncludeCache CreateEvaluatedIncludeCache(IResourceG }.ToImmutableHashSet()) }.ToImmutableHashSet()); - var cache = new EvaluatedIncludeCache(); + var cache = new EvaluatedIncludeCache(Array.Empty()); cache.Set(include); return cache; } diff --git a/benchmarks/Tools/NeverResourceDefinitionAccessor.cs b/benchmarks/Tools/NeverResourceDefinitionAccessor.cs index 6e93519dae..3de20cb7fd 100644 --- a/benchmarks/Tools/NeverResourceDefinitionAccessor.cs +++ b/benchmarks/Tools/NeverResourceDefinitionAccessor.cs @@ -12,6 +12,8 @@ namespace Benchmarks.Tools; /// internal sealed class NeverResourceDefinitionAccessor : IResourceDefinitionAccessor { + bool IResourceDefinitionAccessor.IsReadOnlyRequest => throw new NotImplementedException(); + public IImmutableSet OnApplyIncludes(ResourceType resourceType, IImmutableSet existingIncludes) { return existingIncludes; diff --git a/docs/build-dev.ps1 b/docs/build-dev.ps1 index 5212429b7d..bdd13d16b8 100644 --- a/docs/build-dev.ps1 +++ b/docs/build-dev.ps1 @@ -1,4 +1,4 @@ -#Requires -Version 7.0 +#Requires -Version 7.3 # This script builds the documentation website, starts a web server and opens the site in your browser. Intended for local development. diff --git a/docs/generate-examples.ps1 b/docs/generate-examples.ps1 index 468b8447ac..cbe7d13e9d 100644 --- a/docs/generate-examples.ps1 +++ b/docs/generate-examples.ps1 @@ -1,4 +1,4 @@ -#Requires -Version 7.0 +#Requires -Version 7.3 # This script generates response documents for ./request-examples diff --git a/docs/getting-started/faq.md b/docs/getting-started/faq.md index 2acde9d7a8..e1caa85797 100644 --- a/docs/getting-started/faq.md +++ b/docs/getting-started/faq.md @@ -125,7 +125,7 @@ Your controller method needs to store the request state (URL, query string, and From within your background process job handler, reconstruct the request state, execute the appropriate `JsonApiResourceService` method and store the result. There's a basic example available at https://github.com/json-api-dotnet/JsonApiDotNetCore/pull/1144, which processes a captured query string. -### What if I want to use something other than Entity Framework Core? +#### What if I want to use something other than Entity Framework Core? This basically means you'll need to implement data access yourself. There are two approaches for interception: at the resource service level and at the repository level. Either way, you can use the built-in query string and request body parsing, as well as routing, error handling, and rendering of responses. @@ -133,24 +133,24 @@ Here are some injectable request-scoped types to be aware of: - `IJsonApiRequest`: This contains routing information, such as whether a primary, secondary, or relationship endpoint is being accessed. - `ITargetedFields`: Lists the attributes and relationships from an incoming POST/PATCH resource request. Any fields missing there should not be stored (partial updates). - `IEnumerable`: Provides access to the parsed query string parameters. -- `IEvaluatedIncludeCache`: This tells the response serializer which related resources to render, which you need to populate. -- `ISparseFieldSetCache`: This tells the response serializer which fields to render in the attributes and relationship objects. You need to populate this as well. +- `IEvaluatedIncludeCache`: This tells the response serializer which related resources to render. +- `ISparseFieldSetCache`: This tells the response serializer which fields to render in the `attributes` and `relationships` objects. -You may also want to inject the singletons `IJsonApiOptions` (which contains settings such as default page size) and `IResourceGraph` (the JSON:API model of resources and relationships). +You may also want to inject the singletons `IJsonApiOptions` (which contains settings such as default page size) and `IResourceGraph` (the JSON:API model of resources, attributes and relationships). So, back to the topic of where to intercept. It helps to familiarize yourself with the [execution pipeline](~/internals/queries.md). Replacing at the service level is the simplest. But it means you'll need to read the parsed query string parameters and invoke all resource definition callbacks yourself. And you won't get change detection (HTTP 203 Not Modified). Take a look at [JsonApiResourceService](https://github.com/json-api-dotnet/JsonApiDotNetCore/blob/master/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs) to see what you're missing out on. -You'll get a lot more out of the box if replacing at the repository level instead. You don't need to apply options, analyze query strings or populate caches for the serializer. +You'll get a lot more out of the box if replacing at the repository level instead. You don't need to apply options or analyze query strings. And most resource definition callbacks are handled. That's because the built-in resource service translates all JSON:API aspects of the request into a database-agnostic data structure called `QueryLayer`. Now the hard part for you becomes reading that data structure and producing data access calls from that. If your data store provides a LINQ provider, you may reuse most of [QueryableBuilder](https://github.com/json-api-dotnet/JsonApiDotNetCore/blob/master/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryableBuilder.cs), which drives the translation into [System.Linq.Expressions](https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/concepts/expression-trees/). -Note however, that it also produces calls to `.Include("")`, which is an Entity Framework Core-specific extension method, so you'll likely need to prevent that from happening. -We use this for accessing [MongoDB](https://github.com/json-api-dotnet/JsonApiDotNetCore.MongoDb/blob/674889e037334e3f376550178ce12d0842d7560c/src/JsonApiDotNetCore.MongoDb/Queries/Internal/QueryableBuilding/MongoQueryableBuilder.cs). +Note however, that it also produces calls to `.Include("")`, which is an Entity Framework Core-specific extension method, so you'll likely need to prevent that from happening. There's an example [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/blob/master/src/Examples/NoEntityFrameworkExample/Repositories/InMemoryResourceRepository.cs). +We use a similar approach for accessing [MongoDB](https://github.com/json-api-dotnet/JsonApiDotNetCore.MongoDb/blob/674889e037334e3f376550178ce12d0842d7560c/src/JsonApiDotNetCore.MongoDb/Queries/Internal/QueryableBuilding/MongoQueryableBuilder.cs). > [!TIP] > [ExpressionTreeVisualizer](https://github.com/zspitz/ExpressionTreeVisualizer) is very helpful in trying to debug LINQ expression trees! diff --git a/docs/internals/queries.md b/docs/internals/queries.md index 5c2b238a18..76f062c233 100644 --- a/docs/internals/queries.md +++ b/docs/internals/queries.md @@ -29,7 +29,7 @@ Processing a request involves the following steps: To get a sense of what this all looks like, let's look at an example query string: ``` -/api/v1/blogs? +/api/blogs? include=owner,posts.comments.author& filter=has(posts)& sort=count(posts)& diff --git a/docs/request-examples/001_GET_Books.ps1 b/docs/request-examples/001_GET_Books.ps1 index 559bbfb4d5..f89f9cdd4f 100644 --- a/docs/request-examples/001_GET_Books.ps1 +++ b/docs/request-examples/001_GET_Books.ps1 @@ -1 +1,3 @@ +#Requires -Version 7.3 + curl -s -f http://localhost:14141/api/books diff --git a/docs/request-examples/002_GET_Person-by-ID.ps1 b/docs/request-examples/002_GET_Person-by-ID.ps1 index d565c7cf53..77851b3116 100644 --- a/docs/request-examples/002_GET_Person-by-ID.ps1 +++ b/docs/request-examples/002_GET_Person-by-ID.ps1 @@ -1 +1,3 @@ +#Requires -Version 7.3 + curl -s -f http://localhost:14141/api/people/1 diff --git a/docs/request-examples/003_GET_Books-including-Author.ps1 b/docs/request-examples/003_GET_Books-including-Author.ps1 index 33f5dcd487..6dd71f1f4e 100644 --- a/docs/request-examples/003_GET_Books-including-Author.ps1 +++ b/docs/request-examples/003_GET_Books-including-Author.ps1 @@ -1 +1,3 @@ +#Requires -Version 7.3 + curl -s -f http://localhost:14141/api/books?include=author diff --git a/docs/request-examples/004_GET_Books-PublishYear.ps1 b/docs/request-examples/004_GET_Books-PublishYear.ps1 index a08cb7e6a0..d07cc7bc1a 100644 --- a/docs/request-examples/004_GET_Books-PublishYear.ps1 +++ b/docs/request-examples/004_GET_Books-PublishYear.ps1 @@ -1 +1,3 @@ +#Requires -Version 7.3 + curl -s -f http://localhost:14141/api/books?fields%5Bbooks%5D=publishYear diff --git a/docs/request-examples/005_GET_People-Filter_Partial.ps1 b/docs/request-examples/005_GET_People-Filter_Partial.ps1 index 2e2339f76c..092a54ef1e 100644 --- a/docs/request-examples/005_GET_People-Filter_Partial.ps1 +++ b/docs/request-examples/005_GET_People-Filter_Partial.ps1 @@ -1 +1,3 @@ +#Requires -Version 7.3 + curl -s -f "http://localhost:14141/api/people?filter=contains(name,'Shell')" diff --git a/docs/request-examples/006_GET_Books-sorted-by-PublishYear-descending.ps1 b/docs/request-examples/006_GET_Books-sorted-by-PublishYear-descending.ps1 index 309ad6dcc6..fd96cb3c86 100644 --- a/docs/request-examples/006_GET_Books-sorted-by-PublishYear-descending.ps1 +++ b/docs/request-examples/006_GET_Books-sorted-by-PublishYear-descending.ps1 @@ -1 +1,3 @@ +#Requires -Version 7.3 + curl -s -f http://localhost:14141/api/books?sort=-publishYear diff --git a/docs/request-examples/007_GET_Books-paginated.ps1 b/docs/request-examples/007_GET_Books-paginated.ps1 index c088163a52..a744886801 100644 --- a/docs/request-examples/007_GET_Books-paginated.ps1 +++ b/docs/request-examples/007_GET_Books-paginated.ps1 @@ -1 +1,3 @@ +#Requires -Version 7.3 + curl -s -f "http://localhost:14141/api/books?page%5Bsize%5D=1&page%5Bnumber%5D=2" diff --git a/docs/request-examples/010_CREATE_Person.ps1 b/docs/request-examples/010_CREATE_Person.ps1 index 1a76f0cad1..e8f95020cd 100644 --- a/docs/request-examples/010_CREATE_Person.ps1 +++ b/docs/request-examples/010_CREATE_Person.ps1 @@ -1,10 +1,12 @@ +#Requires -Version 7.3 + curl -s -f http://localhost:14141/api/people ` -H "Content-Type: application/vnd.api+json" ` -d '{ - \"data\": { - \"type\": \"people\", - \"attributes\": { - \"name\": \"Alice\" + "data": { + "type": "people", + "attributes": { + "name": "Alice" } } }' diff --git a/docs/request-examples/011_CREATE_Book-with-Author.ps1 b/docs/request-examples/011_CREATE_Book-with-Author.ps1 index bf839f5a85..0737689408 100644 --- a/docs/request-examples/011_CREATE_Book-with-Author.ps1 +++ b/docs/request-examples/011_CREATE_Book-with-Author.ps1 @@ -1,17 +1,19 @@ +#Requires -Version 7.3 + curl -s -f http://localhost:14141/api/books ` -H "Content-Type: application/vnd.api+json" ` -d '{ - \"data\": { - \"type\": \"books\", - \"attributes\": { - \"title\": \"Valperga\", - \"publishYear\": 1823 + "data": { + "type": "books", + "attributes": { + "title": "Valperga", + "publishYear": 1823 }, - \"relationships\": { - \"author\": { - \"data\": { - \"type\": \"people\", - \"id\": \"1\" + "relationships": { + "author": { + "data": { + "type": "people", + "id": "1" } } } diff --git a/docs/request-examples/012_PATCH_Book.ps1 b/docs/request-examples/012_PATCH_Book.ps1 index d704c8c8c8..61ea6bee76 100644 --- a/docs/request-examples/012_PATCH_Book.ps1 +++ b/docs/request-examples/012_PATCH_Book.ps1 @@ -1,12 +1,14 @@ +#Requires -Version 7.3 + curl -s -f http://localhost:14141/api/books/1 ` -H "Content-Type: application/vnd.api+json" ` -X PATCH ` -d '{ - \"data\": { - \"type\": \"books\", - \"id\": \"1\", - \"attributes\": { - \"publishYear\": 1820 + "data": { + "type": "books", + "id": "1", + "attributes": { + "publishYear": 1820 } } }' diff --git a/docs/request-examples/013_DELETE_Book.ps1 b/docs/request-examples/013_DELETE_Book.ps1 index d5fdd8e103..bbd7ba7445 100644 --- a/docs/request-examples/013_DELETE_Book.ps1 +++ b/docs/request-examples/013_DELETE_Book.ps1 @@ -1,2 +1,4 @@ +#Requires -Version 7.3 + curl -s -f http://localhost:14141/api/books/1 ` -X DELETE diff --git a/docs/usage/options.md b/docs/usage/options.md index 919642c5c8..549bfc454c 100644 --- a/docs/usage/options.md +++ b/docs/usage/options.md @@ -22,7 +22,7 @@ options.AllowClientGeneratedIds = true; ## Pagination -The default page size used for all resources can be overridden in options (10 by default). To disable paging, set it to `null`. +The default page size used for all resources can be overridden in options (10 by default). To disable pagination, set it to `null`. The maximum page size and number allowed from client requests can be set too (unconstrained by default). You can also include the total number of resources in each response. @@ -38,11 +38,11 @@ options.IncludeTotalResourceCount = true; ``` To retrieve the total number of resources on secondary and relationship endpoints, the reverse of the relationship must to be available. For example, in `GET /customers/1/orders`, both the relationships `[HasMany] Customer.Orders` and `[HasOne] Order.Customer` must be defined. -If `IncludeTotalResourceCount` is set to `false` (or the inverse relationship is unavailable on a non-primary endpoint), best-effort paging links are returned instead. This means no `last` link and the `next` link only occurs when the current page is full. +If `IncludeTotalResourceCount` is set to `false` (or the inverse relationship is unavailable on a non-primary endpoint), best-effort pagination links are returned instead. This means no `last` link and the `next` link only occurs when the current page is full. ## Relative Links -All links are absolute by default. However, you can configure relative links. +All links are absolute by default. However, you can configure relative links: ```c# options.UseRelativeLinks = true; @@ -55,8 +55,8 @@ options.UseRelativeLinks = true; "relationships": { "author": { "links": { - "self": "/api/v1/articles/4309/relationships/author", - "related": "/api/v1/articles/4309/author" + "self": "/articles/4309/relationships/author", + "related": "/articles/4309/author" } } } diff --git a/docs/usage/routing.md b/docs/usage/routing.md index a264622931..e3e021ec23 100644 --- a/docs/usage/routing.md +++ b/docs/usage/routing.md @@ -11,10 +11,10 @@ You can add a namespace to all URLs by specifying it at startup. ```c# // Program.cs -builder.Services.AddJsonApi(options => options.Namespace = "api/v1"); +builder.Services.AddJsonApi(options => options.Namespace = "api/shopping"); ``` -Which results in URLs like: https://yourdomain.com/api/v1/people +Which results in URLs like: https://yourdomain.com/api/shopping/articles ## Default routing convention @@ -66,14 +66,14 @@ It is possible to override the default routing convention for an auto-generated ```c# // Auto-generated [DisableRoutingConvention] -[Route("v1/custom/route/summaries-for-orders")] +[Route("custom/route/summaries-for-orders")] partial class OrderSummariesController { } // Hand-written [DisableRoutingConvention] -[Route("v1/custom/route/lines-in-order")] +[Route("custom/route/lines-in-order")] public class OrderLineController : JsonApiController { public OrderLineController(IJsonApiOptions options, IResourceGraph resourceGraph, diff --git a/docs/usage/toc.md b/docs/usage/toc.md index 9bf1191e91..6f90725cba 100644 --- a/docs/usage/toc.md +++ b/docs/usage/toc.md @@ -23,7 +23,6 @@ # [Errors](errors.md) # [Metadata](meta.md) # [Caching](caching.md) - # [Common Pitfalls](common-pitfalls.md) # [OpenAPI](openapi.md) diff --git a/src/Examples/DatabasePerTenantExample/Data/AppDbContext.cs b/src/Examples/DatabasePerTenantExample/Data/AppDbContext.cs index ba73b8bf3a..cfc82ab27a 100644 --- a/src/Examples/DatabasePerTenantExample/Data/AppDbContext.cs +++ b/src/Examples/DatabasePerTenantExample/Data/AppDbContext.cs @@ -16,7 +16,8 @@ public sealed class AppDbContext : DbContext public DbSet Employees => Set(); - public AppDbContext(IHttpContextAccessor httpContextAccessor, IConfiguration configuration) + public AppDbContext(DbContextOptions options, IHttpContextAccessor httpContextAccessor, IConfiguration configuration) + : base(options) { _httpContextAccessor = httpContextAccessor; _configuration = configuration; @@ -27,16 +28,16 @@ public void SetTenantName(string tenantName) _forcedTenantName = tenantName; } - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + protected override void OnConfiguring(DbContextOptionsBuilder builder) { string connectionString = GetConnectionString(); - optionsBuilder.UseNpgsql(connectionString); + builder.UseNpgsql(connectionString); } private string GetConnectionString() { string? tenantName = GetTenantName(); - string? connectionString = _configuration[$"Data:{tenantName ?? "Default"}Connection"]; + string? connectionString = _configuration.GetConnectionString(tenantName ?? "Default"); if (connectionString == null) { diff --git a/src/Examples/DatabasePerTenantExample/DatabasePerTenantExample.csproj b/src/Examples/DatabasePerTenantExample/DatabasePerTenantExample.csproj index b243e99ec2..a48f472a70 100644 --- a/src/Examples/DatabasePerTenantExample/DatabasePerTenantExample.csproj +++ b/src/Examples/DatabasePerTenantExample/DatabasePerTenantExample.csproj @@ -10,7 +10,7 @@ - + diff --git a/src/Examples/DatabasePerTenantExample/Program.cs b/src/Examples/DatabasePerTenantExample/Program.cs index b6f960831d..1414e28424 100644 --- a/src/Examples/DatabasePerTenantExample/Program.cs +++ b/src/Examples/DatabasePerTenantExample/Program.cs @@ -1,20 +1,29 @@ +using System.Diagnostics; using DatabasePerTenantExample.Data; using DatabasePerTenantExample.Models; using JsonApiDotNetCore.Configuration; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services.AddSingleton(); -builder.Services.AddDbContext(options => options.UseNpgsql()); + +builder.Services.AddDbContext(options => SetDbContextDebugOptions(options)); builder.Services.AddJsonApi(options => { options.Namespace = "api"; options.UseRelativeLinks = true; + options.IncludeTotalResourceCount = true; + +#if DEBUG + options.IncludeExceptionStackTraceInErrors = true; + options.IncludeRequestBodyInErrors = true; options.SerializerOptions.WriteIndented = true; +#endif }); WebApplication app = builder.Build(); @@ -31,6 +40,14 @@ app.Run(); +[Conditional("DEBUG")] +static void SetDbContextDebugOptions(DbContextOptionsBuilder options) +{ + options.EnableDetailedErrors(); + options.EnableSensitiveDataLogging(); + options.ConfigureWarnings(builder => builder.Ignore(CoreEventId.SensitiveDataLoggingEnabledWarning)); +} + static async Task CreateDatabaseAsync(string? tenantName, IServiceProvider serviceProvider) { await using AsyncServiceScope scope = serviceProvider.CreateAsyncScope(); @@ -41,18 +58,18 @@ static async Task CreateDatabaseAsync(string? tenantName, IServiceProvider servi dbContext.SetTenantName(tenantName); } - await dbContext.Database.EnsureDeletedAsync(); - await dbContext.Database.EnsureCreatedAsync(); - - if (tenantName != null) + if (await dbContext.Database.EnsureCreatedAsync()) { - dbContext.Employees.Add(new Employee + if (tenantName != null) { - FirstName = "John", - LastName = "Doe", - CompanyName = tenantName - }); + dbContext.Employees.Add(new Employee + { + FirstName = "John", + LastName = "Doe", + CompanyName = tenantName + }); - await dbContext.SaveChangesAsync(); + await dbContext.SaveChangesAsync(); + } } } diff --git a/src/Examples/DatabasePerTenantExample/appsettings.json b/src/Examples/DatabasePerTenantExample/appsettings.json index c065f66c64..01687be022 100644 --- a/src/Examples/DatabasePerTenantExample/appsettings.json +++ b/src/Examples/DatabasePerTenantExample/appsettings.json @@ -1,13 +1,15 @@ { - "Data": { - "DefaultConnection": "Host=localhost;Port=5432;Database=DefaultTenantDb;User ID=postgres;Password=###", - "AdventureWorksConnection": "Host=localhost;Port=5432;Database=AdventureWorks;User ID=postgres;Password=###", - "ContosoConnection": "Host=localhost;Port=5432;Database=Contoso;User ID=postgres;Password=###" + "ConnectionStrings": { + "Default": "Host=localhost;Database=DefaultTenantDb;User ID=postgres;Password=###;Include Error Detail=true", + "AdventureWorks": "Host=localhost;Database=AdventureWorks;User ID=postgres;Password=###;Include Error Detail=true", + "Contoso": "Host=localhost;Database=Contoso;User ID=postgres;Password=###;Include Error Detail=true" }, "Logging": { "LogLevel": { "Default": "Warning", + // Include server startup, incoming requests and SQL commands. "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.AspNetCore.Hosting.Diagnostics": "Information", "Microsoft.EntityFrameworkCore.Database.Command": "Information" } }, diff --git a/src/Examples/GettingStarted/Program.cs b/src/Examples/GettingStarted/Program.cs index cf19380c6c..9ce6beda08 100644 --- a/src/Examples/GettingStarted/Program.cs +++ b/src/Examples/GettingStarted/Program.cs @@ -1,19 +1,31 @@ +using System.Diagnostics; using GettingStarted.Data; using GettingStarted.Models; using JsonApiDotNetCore.Configuration; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); // Add services to the container. -builder.Services.AddSqlite("Data Source=sample.db;Pooling=False"); +builder.Services.AddDbContext(options => +{ + options.UseSqlite("Data Source=SampleDb.db;Pooling=False"); + SetDbContextDebugOptions(options); +}); builder.Services.AddJsonApi(options => { options.Namespace = "api"; options.UseRelativeLinks = true; options.IncludeTotalResourceCount = true; + +#if DEBUG + options.IncludeExceptionStackTraceInErrors = true; + options.IncludeRequestBodyInErrors = true; options.SerializerOptions.WriteIndented = true; +#endif }); WebApplication app = builder.Build(); @@ -28,6 +40,14 @@ app.Run(); +[Conditional("DEBUG")] +static void SetDbContextDebugOptions(DbContextOptionsBuilder options) +{ + options.EnableDetailedErrors(); + options.EnableSensitiveDataLogging(); + options.ConfigureWarnings(builder => builder.Ignore(CoreEventId.SensitiveDataLoggingEnabledWarning)); +} + static async Task CreateDatabaseAsync(IServiceProvider serviceProvider) { await using AsyncServiceScope scope = serviceProvider.CreateAsyncScope(); diff --git a/src/Examples/GettingStarted/Properties/launchSettings.json b/src/Examples/GettingStarted/Properties/launchSettings.json index bcf154605c..d806502bcd 100644 --- a/src/Examples/GettingStarted/Properties/launchSettings.json +++ b/src/Examples/GettingStarted/Properties/launchSettings.json @@ -11,7 +11,7 @@ "IIS Express": { "commandName": "IISExpress", "launchBrowser": true, - "launchUrl": "api/people", + "launchUrl": "api/people?include=books", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -19,7 +19,7 @@ "Kestrel": { "commandName": "Project", "launchBrowser": true, - "launchUrl": "api/people", + "launchUrl": "api/people?include=books", "applicationUrl": "http://localhost:14141", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/src/Examples/GettingStarted/appsettings.json b/src/Examples/GettingStarted/appsettings.json index 270cabc088..590851ee61 100644 --- a/src/Examples/GettingStarted/appsettings.json +++ b/src/Examples/GettingStarted/appsettings.json @@ -2,7 +2,9 @@ "Logging": { "LogLevel": { "Default": "Warning", + // Include server startup, incoming requests and SQL commands. "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.AspNetCore.Hosting.Diagnostics": "Information", "Microsoft.EntityFrameworkCore.Database.Command": "Information" } }, diff --git a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs index 24378e3182..dd30287500 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs @@ -1,6 +1,7 @@ using JetBrains.Annotations; using JsonApiDotNetCoreExample.Models; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; // @formatter:wrap_chained_method_calls chop_always @@ -18,14 +19,33 @@ public AppDbContext(DbContextOptions options) protected override void OnModelCreating(ModelBuilder builder) { - // When deleting a person, un-assign him/her from existing todo items. + // When deleting a person, un-assign him/her from existing todo-items. builder.Entity() .HasMany(person => person.AssignedTodoItems) - .WithOne(todoItem => todoItem.Assignee!); + .WithOne(todoItem => todoItem.Assignee); - // When deleting a person, the todo items he/she owns are deleted too. - builder.Entity() - .HasOne(todoItem => todoItem.Owner) - .WithMany(); + // When deleting a person, the todo-items he/she owns are deleted too. + builder.Entity() + .HasMany(person => person.OwnedTodoItems) + .WithOne(todoItem => todoItem.Owner); + + AdjustDeleteBehaviorForJsonApi(builder); + } + + private static void AdjustDeleteBehaviorForJsonApi(ModelBuilder builder) + { + foreach (IMutableForeignKey foreignKey in builder.Model.GetEntityTypes() + .SelectMany(entityType => entityType.GetForeignKeys())) + { + if (foreignKey.DeleteBehavior == DeleteBehavior.ClientSetNull) + { + foreignKey.DeleteBehavior = DeleteBehavior.SetNull; + } + + if (foreignKey.DeleteBehavior == DeleteBehavior.ClientCascade) + { + foreignKey.DeleteBehavior = DeleteBehavior.Cascade; + } + } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Data/RotatingList.cs b/src/Examples/JsonApiDotNetCoreExample/Data/RotatingList.cs new file mode 100644 index 0000000000..59247532b9 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/Data/RotatingList.cs @@ -0,0 +1,35 @@ +namespace JsonApiDotNetCoreExample.Data; + +internal abstract class RotatingList +{ + public static RotatingList Create(int count, Func createElement) + { + List elements = new(); + + for (int index = 0; index < count; index++) + { + T element = createElement(index); + elements.Add(element); + } + + return new RotatingList(elements); + } +} + +internal sealed class RotatingList +{ + private int _index = -1; + + public IList Elements { get; } + + public RotatingList(IList elements) + { + Elements = elements; + } + + public T GetNext() + { + _index++; + return Elements[_index % Elements.Count]; + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Data/Seeder.cs b/src/Examples/JsonApiDotNetCoreExample/Data/Seeder.cs new file mode 100644 index 0000000000..3bc2e4bacf --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/Data/Seeder.cs @@ -0,0 +1,56 @@ +using JetBrains.Annotations; +using JsonApiDotNetCoreExample.Models; + +namespace JsonApiDotNetCoreExample.Data; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +internal sealed class Seeder +{ + public static async Task CreateSampleDataAsync(AppDbContext dbContext) + { + const int todoItemCount = 500; + const int personCount = 50; + const int tagCount = 25; + + RotatingList people = RotatingList.Create(personCount, index => new Person + { + FirstName = $"FirstName{index + 1:D2}", + LastName = $"LastName{index + 1:D2}" + }); + + RotatingList tags = RotatingList.Create(tagCount, index => new Tag + { + Name = $"TagName{index + 1:D2}" + }); + + RotatingList priorities = RotatingList.Create(3, index => (TodoItemPriority)(index + 1)); + + RotatingList todoItems = RotatingList.Create(todoItemCount, index => + { + var todoItem = new TodoItem + { + Description = $"TodoItem{index + 1:D3}", + Priority = priorities.GetNext(), + DurationInHours = index, + CreatedAt = DateTimeOffset.UtcNow, + Owner = people.GetNext(), + Tags = new HashSet + { + tags.GetNext(), + tags.GetNext(), + tags.GetNext() + } + }; + + if (index % 3 == 0) + { + todoItem.Assignee = people.GetNext(); + } + + return todoItem; + }); + + dbContext.TodoItems.AddRange(todoItems.Elements); + await dbContext.SaveChangesAsync(); + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemDefinition.cs b/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemDefinition.cs index ee7b874fc4..c533143855 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemDefinition.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemDefinition.cs @@ -10,7 +10,7 @@ namespace JsonApiDotNetCoreExample.Definitions; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] -public sealed class TodoItemDefinition : JsonApiResourceDefinition +public sealed class TodoItemDefinition : JsonApiResourceDefinition { private readonly ISystemClock _systemClock; @@ -29,7 +29,7 @@ private SortExpression GetDefaultSortOrder() { return CreateSortExpressionFromLambda(new PropertySortOrder { - (todoItem => todoItem.Priority, ListSortDirection.Descending), + (todoItem => todoItem.Priority, ListSortDirection.Ascending), (todoItem => todoItem.LastModifiedAt, ListSortDirection.Descending) }); } diff --git a/src/Examples/JsonApiDotNetCoreExample/JsonApiDotNetCoreExample.csproj b/src/Examples/JsonApiDotNetCoreExample/JsonApiDotNetCoreExample.csproj index 32dcff4edf..d026923404 100644 --- a/src/Examples/JsonApiDotNetCoreExample/JsonApiDotNetCoreExample.csproj +++ b/src/Examples/JsonApiDotNetCoreExample/JsonApiDotNetCoreExample.csproj @@ -11,7 +11,7 @@ - + diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs index 5415d37bb3..d11fbffff6 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations.Schema; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; @@ -6,7 +7,7 @@ namespace JsonApiDotNetCoreExample.Models; [UsedImplicitly(ImplicitUseTargetFlags.Members)] [Resource] -public sealed class Person : Identifiable +public sealed class Person : Identifiable { [Attr] public string? FirstName { get; set; } @@ -14,6 +15,13 @@ public sealed class Person : Identifiable [Attr] public string LastName { get; set; } = null!; + [Attr(Capabilities = AttrCapabilities.AllowView)] + [NotMapped] + public string DisplayName => FirstName != null ? $"{FirstName} {LastName}" : LastName; + + [HasMany] + public ISet OwnedTodoItems { get; set; } = new HashSet(); + [HasMany] public ISet AssignedTodoItems { get; set; } = new HashSet(); } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs index 9095b0af80..8904ec01a3 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs @@ -7,7 +7,7 @@ namespace JsonApiDotNetCoreExample.Models; [UsedImplicitly(ImplicitUseTargetFlags.Members)] [Resource] -public sealed class Tag : Identifiable +public sealed class Tag : Identifiable { [Attr] [MinLength(1)] diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs index 5fe508f7f2..68df7cef27 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs @@ -7,7 +7,7 @@ namespace JsonApiDotNetCoreExample.Models; [UsedImplicitly(ImplicitUseTargetFlags.Members)] [Resource] -public sealed class TodoItem : Identifiable +public sealed class TodoItem : Identifiable { [Attr] public string Description { get; set; } = null!; @@ -16,6 +16,9 @@ public sealed class TodoItem : Identifiable [Required] public TodoItemPriority? Priority { get; set; } + [Attr] + public long? DurationInHours { get; set; } + [Attr(Capabilities = AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort | AttrCapabilities.AllowView)] public DateTimeOffset CreatedAt { get; set; } @@ -25,9 +28,9 @@ public sealed class TodoItem : Identifiable [HasOne] public Person Owner { get; set; } = null!; - [HasOne(Capabilities = HasOneCapabilities.AllowView | HasOneCapabilities.AllowSet)] + [HasOne] public Person? Assignee { get; set; } - [HasMany(Capabilities = HasManyCapabilities.AllowView | HasManyCapabilities.AllowFilter)] + [HasMany] public ISet Tags { get; set; } = new HashSet(); } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemPriority.cs b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemPriority.cs index 9ef85348f1..84e3567b31 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemPriority.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemPriority.cs @@ -5,7 +5,7 @@ namespace JsonApiDotNetCoreExample.Models; [UsedImplicitly(ImplicitUseTargetFlags.Members)] public enum TodoItemPriority { - Low, - Medium, - High + High = 1, + Medium = 2, + Low = 3 } diff --git a/src/Examples/JsonApiDotNetCoreExample/Program.cs b/src/Examples/JsonApiDotNetCoreExample/Program.cs index 8a05547b61..7243968f1a 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Program.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Program.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; using JsonApiDotNetCore.Configuration; @@ -6,6 +7,7 @@ using JsonApiDotNetCoreExample.Data; using Microsoft.AspNetCore.Authentication; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.DependencyInjection.Extensions; [assembly: ExcludeFromCodeCoverage] @@ -49,12 +51,9 @@ static void ConfigureServices(WebApplicationBuilder builder) builder.Services.AddDbContext(options => { string? connectionString = GetConnectionString(builder.Configuration); - options.UseNpgsql(connectionString); -#if DEBUG - options.EnableSensitiveDataLogging(); - options.EnableDetailedErrors(); -#endif + + SetDbContextDebugOptions(options); }); IMvcCoreBuilder mvcCoreBuilder = builder.Services.AddMvcCore(); @@ -63,14 +62,15 @@ static void ConfigureServices(WebApplicationBuilder builder) { builder.Services.AddJsonApi(options => { - options.Namespace = "api/v1"; + options.Namespace = "api"; options.UseRelativeLinks = true; options.IncludeTotalResourceCount = true; - options.SerializerOptions.WriteIndented = true; options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); + #if DEBUG options.IncludeExceptionStackTraceInErrors = true; options.IncludeRequestBodyInErrors = true; + options.SerializerOptions.WriteIndented = true; #endif }, discovery => discovery.AddCurrentAssembly(), mvcBuilder: mvcCoreBuilder); } @@ -84,7 +84,15 @@ static void ConfigureServices(WebApplicationBuilder builder) static string? GetConnectionString(IConfiguration configuration) { string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; - return configuration["Data:DefaultConnection"]?.Replace("###", postgresPassword); + return configuration.GetConnectionString("Default")?.Replace("###", postgresPassword); +} + +[Conditional("DEBUG")] +static void SetDbContextDebugOptions(DbContextOptionsBuilder options) +{ + options.EnableDetailedErrors(); + options.EnableSensitiveDataLogging(); + options.ConfigureWarnings(builder => builder.Ignore(CoreEventId.SensitiveDataLoggingEnabledWarning)); } static void ConfigurePipeline(WebApplication webApplication) @@ -109,5 +117,9 @@ static async Task CreateDatabaseAsync(IServiceProvider serviceProvider) await using AsyncServiceScope scope = serviceProvider.CreateAsyncScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); - await dbContext.Database.EnsureCreatedAsync(); + + if (await dbContext.Database.EnsureCreatedAsync()) + { + await Seeder.CreateSampleDataAsync(dbContext); + } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Properties/launchSettings.json b/src/Examples/JsonApiDotNetCoreExample/Properties/launchSettings.json index 6a5108a8ad..54646922e1 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Properties/launchSettings.json +++ b/src/Examples/JsonApiDotNetCoreExample/Properties/launchSettings.json @@ -11,16 +11,16 @@ "profiles": { "IIS Express": { "commandName": "IISExpress", - "launchBrowser": false, - "launchUrl": "api/v1/todoItems", + "launchBrowser": true, + "launchUrl": "api/todoItems?include=owner,assignee,tags&filter=equals(priority,'High')", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } }, "Kestrel": { "commandName": "Project", - "launchBrowser": false, - "launchUrl": "api/v1/todoItems", + "launchBrowser": true, + "launchUrl": "api/todoItems?include=owner,assignee,tags&filter=equals(priority,'High')", "applicationUrl": "https://localhost:44340;http://localhost:14140", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/src/Examples/JsonApiDotNetCoreExample/appsettings.json b/src/Examples/JsonApiDotNetCoreExample/appsettings.json index 0e63c6a380..058685ecb1 100644 --- a/src/Examples/JsonApiDotNetCoreExample/appsettings.json +++ b/src/Examples/JsonApiDotNetCoreExample/appsettings.json @@ -1,15 +1,16 @@ { - "Data": { - "DefaultConnection": "Host=localhost;Port=5432;Database=JsonApiDotNetCoreExample;User ID=postgres;Password=###" + "ConnectionStrings": { + "Default": "Host=localhost;Database=JsonApiDotNetCoreExample;User ID=postgres;Password=###;Include Error Detail=true" }, "Logging": { "LogLevel": { "Default": "Warning", - "Microsoft.Hosting.Lifetime": "Warning", - "Microsoft.EntityFrameworkCore.Update": "Critical", - "Microsoft.EntityFrameworkCore.Database.Command": "Critical", - "JsonApiDotNetCore.Middleware.JsonApiMiddleware": "Information", - "Program": "Information" + // Include server startup, JsonApiDotNetCore measurements, incoming requests and SQL commands. + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.AspNetCore.Hosting.Diagnostics": "Information", + "Microsoft.EntityFrameworkCore.Database.Command": "Information", + "JsonApiDotNetCore": "Information", + "JsonApiDotNetCoreExample": "Information" } }, "AllowedHosts": "*" diff --git a/src/Examples/JsonApiDotNetCoreExampleClient/ExampleApiClient.cs b/src/Examples/JsonApiDotNetCoreExampleClient/ExampleApiClient.cs index 429dae1ac0..fdde8f8fc4 100644 --- a/src/Examples/JsonApiDotNetCoreExampleClient/ExampleApiClient.cs +++ b/src/Examples/JsonApiDotNetCoreExampleClient/ExampleApiClient.cs @@ -1,8 +1,10 @@ +using JetBrains.Annotations; using JsonApiDotNetCore.OpenApi.Client; using Newtonsoft.Json; namespace JsonApiDotNetCoreExampleClient; +[UsedImplicitly(ImplicitUseTargetFlags.Itself)] public partial class ExampleApiClient : JsonApiClient { partial void UpdateJsonSerializerSettings(JsonSerializerSettings settings) diff --git a/src/Examples/JsonApiDotNetCoreExampleClient/OpenAPIs/swagger.json b/src/Examples/JsonApiDotNetCoreExampleClient/OpenAPIs/swagger.json index 9a72c99145..7b5139257e 100644 --- a/src/Examples/JsonApiDotNetCoreExampleClient/OpenAPIs/swagger.json +++ b/src/Examples/JsonApiDotNetCoreExampleClient/OpenAPIs/swagger.json @@ -5,19 +5,19 @@ "version": "1.0" }, "paths": { - "/api/v1/people": { + "/api/people": { "get": { "tags": [ "people" ], - "operationId": "get person Collection", + "operationId": "getPersonCollection", "responses": { "200": { "description": "Success", "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/person-collection-response-document" + "$ref": "#/components/schemas/personCollectionResponseDocument" } } } @@ -28,14 +28,14 @@ "tags": [ "people" ], - "operationId": "head person Collection", + "operationId": "headPersonCollection", "responses": { "200": { "description": "Success", "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/person-collection-response-document" + "$ref": "#/components/schemas/personCollectionResponseDocument" } } } @@ -46,39 +46,39 @@ "tags": [ "people" ], - "operationId": "post person", + "operationId": "postPerson", "requestBody": { "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/person-post-request-document" + "$ref": "#/components/schemas/personPostRequestDocument" } } } }, "responses": { "201": { - "description": "Success", + "description": "Created", "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/person-primary-response-document" + "$ref": "#/components/schemas/personPrimaryResponseDocument" } } } }, "204": { - "description": "Success" + "description": "No Content" } } } }, - "/api/v1/people/{id}": { + "/api/people/{id}": { "get": { "tags": [ "people" ], - "operationId": "get person", + "operationId": "getPerson", "parameters": [ { "name": "id", @@ -86,7 +86,7 @@ "required": true, "schema": { "type": "integer", - "format": "int32" + "format": "int64" } } ], @@ -96,7 +96,7 @@ "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/person-primary-response-document" + "$ref": "#/components/schemas/personPrimaryResponseDocument" } } } @@ -107,7 +107,7 @@ "tags": [ "people" ], - "operationId": "head person", + "operationId": "headPerson", "parameters": [ { "name": "id", @@ -115,7 +115,7 @@ "required": true, "schema": { "type": "integer", - "format": "int32" + "format": "int64" } } ], @@ -125,7 +125,7 @@ "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/person-primary-response-document" + "$ref": "#/components/schemas/personPrimaryResponseDocument" } } } @@ -136,7 +136,7 @@ "tags": [ "people" ], - "operationId": "patch person", + "operationId": "patchPerson", "parameters": [ { "name": "id", @@ -144,7 +144,7 @@ "required": true, "schema": { "type": "integer", - "format": "int32" + "format": "int64" } } ], @@ -152,7 +152,7 @@ "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/person-patch-request-document" + "$ref": "#/components/schemas/personPatchRequestDocument" } } } @@ -163,13 +163,13 @@ "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/person-primary-response-document" + "$ref": "#/components/schemas/personPrimaryResponseDocument" } } } }, "204": { - "description": "Success" + "description": "No Content" } } }, @@ -177,7 +177,7 @@ "tags": [ "people" ], - "operationId": "delete person", + "operationId": "deletePerson", "parameters": [ { "name": "id", @@ -185,23 +185,23 @@ "required": true, "schema": { "type": "integer", - "format": "int32" + "format": "int64" } } ], "responses": { "204": { - "description": "Success" + "description": "No Content" } } } }, - "/api/v1/people/{id}/assignedTodoItems": { + "/api/people/{id}/assignedTodoItems": { "get": { "tags": [ "people" ], - "operationId": "get person assignedTodoItems", + "operationId": "getPersonAssignedTodoItems", "parameters": [ { "name": "id", @@ -209,7 +209,7 @@ "required": true, "schema": { "type": "integer", - "format": "int32" + "format": "int64" } } ], @@ -219,7 +219,7 @@ "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/todoItem-collection-response-document" + "$ref": "#/components/schemas/todoItemCollectionResponseDocument" } } } @@ -230,7 +230,7 @@ "tags": [ "people" ], - "operationId": "head person assignedTodoItems", + "operationId": "headPersonAssignedTodoItems", "parameters": [ { "name": "id", @@ -238,7 +238,7 @@ "required": true, "schema": { "type": "integer", - "format": "int32" + "format": "int64" } } ], @@ -248,7 +248,7 @@ "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/todoItem-collection-response-document" + "$ref": "#/components/schemas/todoItemCollectionResponseDocument" } } } @@ -256,12 +256,12 @@ } } }, - "/api/v1/people/{id}/relationships/assignedTodoItems": { + "/api/people/{id}/relationships/assignedTodoItems": { "get": { "tags": [ "people" ], - "operationId": "get person assignedTodoItems Relationship", + "operationId": "getPersonAssignedTodoItemsRelationship", "parameters": [ { "name": "id", @@ -269,7 +269,7 @@ "required": true, "schema": { "type": "integer", - "format": "int32" + "format": "int64" } } ], @@ -279,7 +279,7 @@ "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/todoItem-identifier-collection-response-document" + "$ref": "#/components/schemas/todoItemIdentifierCollectionResponseDocument" } } } @@ -290,7 +290,7 @@ "tags": [ "people" ], - "operationId": "head person assignedTodoItems Relationship", + "operationId": "headPersonAssignedTodoItemsRelationship", "parameters": [ { "name": "id", @@ -298,7 +298,7 @@ "required": true, "schema": { "type": "integer", - "format": "int32" + "format": "int64" } } ], @@ -308,7 +308,7 @@ "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/todoItem-identifier-collection-response-document" + "$ref": "#/components/schemas/todoItemIdentifierCollectionResponseDocument" } } } @@ -319,7 +319,7 @@ "tags": [ "people" ], - "operationId": "post person assignedTodoItems Relationship", + "operationId": "postPersonAssignedTodoItemsRelationship", "parameters": [ { "name": "id", @@ -327,7 +327,7 @@ "required": true, "schema": { "type": "integer", - "format": "int32" + "format": "int64" } } ], @@ -335,14 +335,14 @@ "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/to-many-todoItem-request-data" + "$ref": "#/components/schemas/toManyTodoItemInRequest" } } } }, "responses": { "204": { - "description": "Success" + "description": "No Content" } } }, @@ -350,7 +350,7 @@ "tags": [ "people" ], - "operationId": "patch person assignedTodoItems Relationship", + "operationId": "patchPersonAssignedTodoItemsRelationship", "parameters": [ { "name": "id", @@ -358,7 +358,7 @@ "required": true, "schema": { "type": "integer", - "format": "int32" + "format": "int64" } } ], @@ -366,14 +366,14 @@ "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/to-many-todoItem-request-data" + "$ref": "#/components/schemas/toManyTodoItemInRequest" } } } }, "responses": { "204": { - "description": "Success" + "description": "No Content" } } }, @@ -381,7 +381,7 @@ "tags": [ "people" ], - "operationId": "delete person assignedTodoItems Relationship", + "operationId": "deletePersonAssignedTodoItemsRelationship", "parameters": [ { "name": "id", @@ -389,7 +389,7 @@ "required": true, "schema": { "type": "integer", - "format": "int32" + "format": "int64" } } ], @@ -397,31 +397,244 @@ "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/to-many-todoItem-request-data" + "$ref": "#/components/schemas/toManyTodoItemInRequest" } } } }, "responses": { "204": { - "description": "Success" + "description": "No Content" } } } }, - "/api/v1/tags": { + "/api/people/{id}/ownedTodoItems": { + "get": { + "tags": [ + "people" + ], + "operationId": "getPersonOwnedTodoItems", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/todoItemCollectionResponseDocument" + } + } + } + } + } + }, + "head": { + "tags": [ + "people" + ], + "operationId": "headPersonOwnedTodoItems", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/todoItemCollectionResponseDocument" + } + } + } + } + } + } + }, + "/api/people/{id}/relationships/ownedTodoItems": { + "get": { + "tags": [ + "people" + ], + "operationId": "getPersonOwnedTodoItemsRelationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/todoItemIdentifierCollectionResponseDocument" + } + } + } + } + } + }, + "head": { + "tags": [ + "people" + ], + "operationId": "headPersonOwnedTodoItemsRelationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/todoItemIdentifierCollectionResponseDocument" + } + } + } + } + } + }, + "post": { + "tags": [ + "people" + ], + "operationId": "postPersonOwnedTodoItemsRelationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/toManyTodoItemInRequest" + } + } + } + }, + "responses": { + "204": { + "description": "No Content" + } + } + }, + "patch": { + "tags": [ + "people" + ], + "operationId": "patchPersonOwnedTodoItemsRelationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/toManyTodoItemInRequest" + } + } + } + }, + "responses": { + "204": { + "description": "No Content" + } + } + }, + "delete": { + "tags": [ + "people" + ], + "operationId": "deletePersonOwnedTodoItemsRelationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/toManyTodoItemInRequest" + } + } + } + }, + "responses": { + "204": { + "description": "No Content" + } + } + } + }, + "/api/tags": { "get": { "tags": [ "tags" ], - "operationId": "get tag Collection", + "operationId": "getTagCollection", "responses": { "200": { "description": "Success", "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/tag-collection-response-document" + "$ref": "#/components/schemas/tagCollectionResponseDocument" } } } @@ -432,14 +645,14 @@ "tags": [ "tags" ], - "operationId": "head tag Collection", + "operationId": "headTagCollection", "responses": { "200": { "description": "Success", "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/tag-collection-response-document" + "$ref": "#/components/schemas/tagCollectionResponseDocument" } } } @@ -450,39 +663,39 @@ "tags": [ "tags" ], - "operationId": "post tag", + "operationId": "postTag", "requestBody": { "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/tag-post-request-document" + "$ref": "#/components/schemas/tagPostRequestDocument" } } } }, "responses": { "201": { - "description": "Success", + "description": "Created", "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/tag-primary-response-document" + "$ref": "#/components/schemas/tagPrimaryResponseDocument" } } } }, "204": { - "description": "Success" + "description": "No Content" } } } }, - "/api/v1/tags/{id}": { + "/api/tags/{id}": { "get": { "tags": [ "tags" ], - "operationId": "get tag", + "operationId": "getTag", "parameters": [ { "name": "id", @@ -490,7 +703,7 @@ "required": true, "schema": { "type": "integer", - "format": "int32" + "format": "int64" } } ], @@ -500,7 +713,7 @@ "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/tag-primary-response-document" + "$ref": "#/components/schemas/tagPrimaryResponseDocument" } } } @@ -511,7 +724,7 @@ "tags": [ "tags" ], - "operationId": "head tag", + "operationId": "headTag", "parameters": [ { "name": "id", @@ -519,7 +732,7 @@ "required": true, "schema": { "type": "integer", - "format": "int32" + "format": "int64" } } ], @@ -529,7 +742,7 @@ "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/tag-primary-response-document" + "$ref": "#/components/schemas/tagPrimaryResponseDocument" } } } @@ -540,7 +753,7 @@ "tags": [ "tags" ], - "operationId": "patch tag", + "operationId": "patchTag", "parameters": [ { "name": "id", @@ -548,7 +761,7 @@ "required": true, "schema": { "type": "integer", - "format": "int32" + "format": "int64" } } ], @@ -556,7 +769,7 @@ "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/tag-patch-request-document" + "$ref": "#/components/schemas/tagPatchRequestDocument" } } } @@ -567,13 +780,13 @@ "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/tag-primary-response-document" + "$ref": "#/components/schemas/tagPrimaryResponseDocument" } } } }, "204": { - "description": "Success" + "description": "No Content" } } }, @@ -581,7 +794,7 @@ "tags": [ "tags" ], - "operationId": "delete tag", + "operationId": "deleteTag", "parameters": [ { "name": "id", @@ -589,23 +802,23 @@ "required": true, "schema": { "type": "integer", - "format": "int32" + "format": "int64" } } ], "responses": { "204": { - "description": "Success" + "description": "No Content" } } } }, - "/api/v1/tags/{id}/todoItems": { + "/api/tags/{id}/todoItems": { "get": { "tags": [ "tags" ], - "operationId": "get tag todoItems", + "operationId": "getTagTodoItems", "parameters": [ { "name": "id", @@ -613,7 +826,7 @@ "required": true, "schema": { "type": "integer", - "format": "int32" + "format": "int64" } } ], @@ -623,7 +836,7 @@ "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/todoItem-collection-response-document" + "$ref": "#/components/schemas/todoItemCollectionResponseDocument" } } } @@ -634,7 +847,7 @@ "tags": [ "tags" ], - "operationId": "head tag todoItems", + "operationId": "headTagTodoItems", "parameters": [ { "name": "id", @@ -642,7 +855,7 @@ "required": true, "schema": { "type": "integer", - "format": "int32" + "format": "int64" } } ], @@ -652,7 +865,7 @@ "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/todoItem-collection-response-document" + "$ref": "#/components/schemas/todoItemCollectionResponseDocument" } } } @@ -660,12 +873,12 @@ } } }, - "/api/v1/tags/{id}/relationships/todoItems": { + "/api/tags/{id}/relationships/todoItems": { "get": { "tags": [ "tags" ], - "operationId": "get tag todoItems Relationship", + "operationId": "getTagTodoItemsRelationship", "parameters": [ { "name": "id", @@ -673,7 +886,7 @@ "required": true, "schema": { "type": "integer", - "format": "int32" + "format": "int64" } } ], @@ -683,7 +896,7 @@ "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/todoItem-identifier-collection-response-document" + "$ref": "#/components/schemas/todoItemIdentifierCollectionResponseDocument" } } } @@ -694,7 +907,7 @@ "tags": [ "tags" ], - "operationId": "head tag todoItems Relationship", + "operationId": "headTagTodoItemsRelationship", "parameters": [ { "name": "id", @@ -702,7 +915,7 @@ "required": true, "schema": { "type": "integer", - "format": "int32" + "format": "int64" } } ], @@ -712,7 +925,7 @@ "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/todoItem-identifier-collection-response-document" + "$ref": "#/components/schemas/todoItemIdentifierCollectionResponseDocument" } } } @@ -723,7 +936,7 @@ "tags": [ "tags" ], - "operationId": "post tag todoItems Relationship", + "operationId": "postTagTodoItemsRelationship", "parameters": [ { "name": "id", @@ -731,7 +944,7 @@ "required": true, "schema": { "type": "integer", - "format": "int32" + "format": "int64" } } ], @@ -739,14 +952,14 @@ "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/to-many-todoItem-request-data" + "$ref": "#/components/schemas/toManyTodoItemInRequest" } } } }, "responses": { "204": { - "description": "Success" + "description": "No Content" } } }, @@ -754,7 +967,7 @@ "tags": [ "tags" ], - "operationId": "patch tag todoItems Relationship", + "operationId": "patchTagTodoItemsRelationship", "parameters": [ { "name": "id", @@ -762,7 +975,7 @@ "required": true, "schema": { "type": "integer", - "format": "int32" + "format": "int64" } } ], @@ -770,14 +983,14 @@ "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/to-many-todoItem-request-data" + "$ref": "#/components/schemas/toManyTodoItemInRequest" } } } }, "responses": { "204": { - "description": "Success" + "description": "No Content" } } }, @@ -785,7 +998,7 @@ "tags": [ "tags" ], - "operationId": "delete tag todoItems Relationship", + "operationId": "deleteTagTodoItemsRelationship", "parameters": [ { "name": "id", @@ -793,7 +1006,7 @@ "required": true, "schema": { "type": "integer", - "format": "int32" + "format": "int64" } } ], @@ -801,31 +1014,31 @@ "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/to-many-todoItem-request-data" + "$ref": "#/components/schemas/toManyTodoItemInRequest" } } } }, "responses": { "204": { - "description": "Success" + "description": "No Content" } } } }, - "/api/v1/todoItems": { + "/api/todoItems": { "get": { "tags": [ "todoItems" ], - "operationId": "get todoItem Collection", + "operationId": "getTodoItemCollection", "responses": { "200": { "description": "Success", "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/todoItem-collection-response-document" + "$ref": "#/components/schemas/todoItemCollectionResponseDocument" } } } @@ -836,14 +1049,14 @@ "tags": [ "todoItems" ], - "operationId": "head todoItem Collection", + "operationId": "headTodoItemCollection", "responses": { "200": { "description": "Success", "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/todoItem-collection-response-document" + "$ref": "#/components/schemas/todoItemCollectionResponseDocument" } } } @@ -854,39 +1067,39 @@ "tags": [ "todoItems" ], - "operationId": "post todoItem", + "operationId": "postTodoItem", "requestBody": { "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/todoItem-post-request-document" + "$ref": "#/components/schemas/todoItemPostRequestDocument" } } } }, "responses": { "201": { - "description": "Success", + "description": "Created", "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/todoItem-primary-response-document" + "$ref": "#/components/schemas/todoItemPrimaryResponseDocument" } } } }, "204": { - "description": "Success" + "description": "No Content" } } } }, - "/api/v1/todoItems/{id}": { + "/api/todoItems/{id}": { "get": { "tags": [ "todoItems" ], - "operationId": "get todoItem", + "operationId": "getTodoItem", "parameters": [ { "name": "id", @@ -894,7 +1107,7 @@ "required": true, "schema": { "type": "integer", - "format": "int32" + "format": "int64" } } ], @@ -904,7 +1117,7 @@ "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/todoItem-primary-response-document" + "$ref": "#/components/schemas/todoItemPrimaryResponseDocument" } } } @@ -915,7 +1128,7 @@ "tags": [ "todoItems" ], - "operationId": "head todoItem", + "operationId": "headTodoItem", "parameters": [ { "name": "id", @@ -923,7 +1136,7 @@ "required": true, "schema": { "type": "integer", - "format": "int32" + "format": "int64" } } ], @@ -933,7 +1146,7 @@ "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/todoItem-primary-response-document" + "$ref": "#/components/schemas/todoItemPrimaryResponseDocument" } } } @@ -944,7 +1157,7 @@ "tags": [ "todoItems" ], - "operationId": "patch todoItem", + "operationId": "patchTodoItem", "parameters": [ { "name": "id", @@ -952,7 +1165,7 @@ "required": true, "schema": { "type": "integer", - "format": "int32" + "format": "int64" } } ], @@ -960,7 +1173,7 @@ "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/todoItem-patch-request-document" + "$ref": "#/components/schemas/todoItemPatchRequestDocument" } } } @@ -971,13 +1184,13 @@ "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/todoItem-primary-response-document" + "$ref": "#/components/schemas/todoItemPrimaryResponseDocument" } } } }, "204": { - "description": "Success" + "description": "No Content" } } }, @@ -985,7 +1198,7 @@ "tags": [ "todoItems" ], - "operationId": "delete todoItem", + "operationId": "deleteTodoItem", "parameters": [ { "name": "id", @@ -993,23 +1206,23 @@ "required": true, "schema": { "type": "integer", - "format": "int32" + "format": "int64" } } ], "responses": { "204": { - "description": "Success" + "description": "No Content" } } } }, - "/api/v1/todoItems/{id}/assignee": { + "/api/todoItems/{id}/assignee": { "get": { "tags": [ "todoItems" ], - "operationId": "get todoItem assignee", + "operationId": "getTodoItemAssignee", "parameters": [ { "name": "id", @@ -1017,7 +1230,7 @@ "required": true, "schema": { "type": "integer", - "format": "int32" + "format": "int64" } } ], @@ -1027,7 +1240,7 @@ "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/person-secondary-response-document" + "$ref": "#/components/schemas/nullablePersonSecondaryResponseDocument" } } } @@ -1038,7 +1251,7 @@ "tags": [ "todoItems" ], - "operationId": "head todoItem assignee", + "operationId": "headTodoItemAssignee", "parameters": [ { "name": "id", @@ -1046,7 +1259,7 @@ "required": true, "schema": { "type": "integer", - "format": "int32" + "format": "int64" } } ], @@ -1056,7 +1269,7 @@ "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/person-secondary-response-document" + "$ref": "#/components/schemas/nullablePersonSecondaryResponseDocument" } } } @@ -1064,12 +1277,12 @@ } } }, - "/api/v1/todoItems/{id}/relationships/assignee": { + "/api/todoItems/{id}/relationships/assignee": { "get": { "tags": [ "todoItems" ], - "operationId": "get todoItem assignee Relationship", + "operationId": "getTodoItemAssigneeRelationship", "parameters": [ { "name": "id", @@ -1077,7 +1290,7 @@ "required": true, "schema": { "type": "integer", - "format": "int32" + "format": "int64" } } ], @@ -1087,7 +1300,7 @@ "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/person-identifier-response-document" + "$ref": "#/components/schemas/nullablePersonIdentifierResponseDocument" } } } @@ -1098,7 +1311,7 @@ "tags": [ "todoItems" ], - "operationId": "head todoItem assignee Relationship", + "operationId": "headTodoItemAssigneeRelationship", "parameters": [ { "name": "id", @@ -1106,7 +1319,7 @@ "required": true, "schema": { "type": "integer", - "format": "int32" + "format": "int64" } } ], @@ -1116,7 +1329,7 @@ "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/person-identifier-response-document" + "$ref": "#/components/schemas/nullablePersonIdentifierResponseDocument" } } } @@ -1127,7 +1340,7 @@ "tags": [ "todoItems" ], - "operationId": "patch todoItem assignee Relationship", + "operationId": "patchTodoItemAssigneeRelationship", "parameters": [ { "name": "id", @@ -1135,7 +1348,7 @@ "required": true, "schema": { "type": "integer", - "format": "int32" + "format": "int64" } } ], @@ -1143,24 +1356,24 @@ "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/to-one-person-request-data" + "$ref": "#/components/schemas/nullableToOnePersonInRequest" } } } }, "responses": { "204": { - "description": "Success" + "description": "No Content" } } } }, - "/api/v1/todoItems/{id}/owner": { + "/api/todoItems/{id}/owner": { "get": { "tags": [ "todoItems" ], - "operationId": "get todoItem owner", + "operationId": "getTodoItemOwner", "parameters": [ { "name": "id", @@ -1168,7 +1381,7 @@ "required": true, "schema": { "type": "integer", - "format": "int32" + "format": "int64" } } ], @@ -1178,7 +1391,7 @@ "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/person-secondary-response-document" + "$ref": "#/components/schemas/personSecondaryResponseDocument" } } } @@ -1189,7 +1402,7 @@ "tags": [ "todoItems" ], - "operationId": "head todoItem owner", + "operationId": "headTodoItemOwner", "parameters": [ { "name": "id", @@ -1197,7 +1410,7 @@ "required": true, "schema": { "type": "integer", - "format": "int32" + "format": "int64" } } ], @@ -1207,7 +1420,7 @@ "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/person-secondary-response-document" + "$ref": "#/components/schemas/personSecondaryResponseDocument" } } } @@ -1215,12 +1428,12 @@ } } }, - "/api/v1/todoItems/{id}/relationships/owner": { + "/api/todoItems/{id}/relationships/owner": { "get": { "tags": [ "todoItems" ], - "operationId": "get todoItem owner Relationship", + "operationId": "getTodoItemOwnerRelationship", "parameters": [ { "name": "id", @@ -1228,7 +1441,7 @@ "required": true, "schema": { "type": "integer", - "format": "int32" + "format": "int64" } } ], @@ -1238,7 +1451,7 @@ "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/person-identifier-response-document" + "$ref": "#/components/schemas/personIdentifierResponseDocument" } } } @@ -1249,7 +1462,7 @@ "tags": [ "todoItems" ], - "operationId": "head todoItem owner Relationship", + "operationId": "headTodoItemOwnerRelationship", "parameters": [ { "name": "id", @@ -1257,7 +1470,7 @@ "required": true, "schema": { "type": "integer", - "format": "int32" + "format": "int64" } } ], @@ -1267,7 +1480,7 @@ "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/person-identifier-response-document" + "$ref": "#/components/schemas/personIdentifierResponseDocument" } } } @@ -1278,7 +1491,7 @@ "tags": [ "todoItems" ], - "operationId": "patch todoItem owner Relationship", + "operationId": "patchTodoItemOwnerRelationship", "parameters": [ { "name": "id", @@ -1286,7 +1499,7 @@ "required": true, "schema": { "type": "integer", - "format": "int32" + "format": "int64" } } ], @@ -1294,24 +1507,24 @@ "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/to-one-person-request-data" + "$ref": "#/components/schemas/toOnePersonInRequest" } } } }, "responses": { "204": { - "description": "Success" + "description": "No Content" } } } }, - "/api/v1/todoItems/{id}/tags": { + "/api/todoItems/{id}/tags": { "get": { "tags": [ "todoItems" ], - "operationId": "get todoItem tags", + "operationId": "getTodoItemTags", "parameters": [ { "name": "id", @@ -1319,7 +1532,7 @@ "required": true, "schema": { "type": "integer", - "format": "int32" + "format": "int64" } } ], @@ -1329,7 +1542,7 @@ "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/tag-collection-response-document" + "$ref": "#/components/schemas/tagCollectionResponseDocument" } } } @@ -1340,7 +1553,7 @@ "tags": [ "todoItems" ], - "operationId": "head todoItem tags", + "operationId": "headTodoItemTags", "parameters": [ { "name": "id", @@ -1348,7 +1561,7 @@ "required": true, "schema": { "type": "integer", - "format": "int32" + "format": "int64" } } ], @@ -1358,7 +1571,7 @@ "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/tag-collection-response-document" + "$ref": "#/components/schemas/tagCollectionResponseDocument" } } } @@ -1366,12 +1579,12 @@ } } }, - "/api/v1/todoItems/{id}/relationships/tags": { + "/api/todoItems/{id}/relationships/tags": { "get": { "tags": [ "todoItems" ], - "operationId": "get todoItem tags Relationship", + "operationId": "getTodoItemTagsRelationship", "parameters": [ { "name": "id", @@ -1379,7 +1592,7 @@ "required": true, "schema": { "type": "integer", - "format": "int32" + "format": "int64" } } ], @@ -1389,7 +1602,7 @@ "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/tag-identifier-collection-response-document" + "$ref": "#/components/schemas/tagIdentifierCollectionResponseDocument" } } } @@ -1400,7 +1613,7 @@ "tags": [ "todoItems" ], - "operationId": "head todoItem tags Relationship", + "operationId": "headTodoItemTagsRelationship", "parameters": [ { "name": "id", @@ -1408,7 +1621,7 @@ "required": true, "schema": { "type": "integer", - "format": "int32" + "format": "int64" } } ], @@ -1418,7 +1631,7 @@ "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/tag-identifier-collection-response-document" + "$ref": "#/components/schemas/tagIdentifierCollectionResponseDocument" } } } @@ -1429,7 +1642,7 @@ "tags": [ "todoItems" ], - "operationId": "post todoItem tags Relationship", + "operationId": "postTodoItemTagsRelationship", "parameters": [ { "name": "id", @@ -1437,7 +1650,7 @@ "required": true, "schema": { "type": "integer", - "format": "int32" + "format": "int64" } } ], @@ -1445,14 +1658,14 @@ "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/to-many-tag-request-data" + "$ref": "#/components/schemas/toManyTagInRequest" } } } }, "responses": { "204": { - "description": "Success" + "description": "No Content" } } }, @@ -1460,7 +1673,7 @@ "tags": [ "todoItems" ], - "operationId": "patch todoItem tags Relationship", + "operationId": "patchTodoItemTagsRelationship", "parameters": [ { "name": "id", @@ -1468,7 +1681,7 @@ "required": true, "schema": { "type": "integer", - "format": "int32" + "format": "int64" } } ], @@ -1476,14 +1689,14 @@ "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/to-many-tag-request-data" + "$ref": "#/components/schemas/toManyTagInRequest" } } } }, "responses": { "204": { - "description": "Success" + "description": "No Content" } } }, @@ -1491,7 +1704,7 @@ "tags": [ "todoItems" ], - "operationId": "delete todoItem tags Relationship", + "operationId": "deleteTodoItemTagsRelationship", "parameters": [ { "name": "id", @@ -1499,7 +1712,7 @@ "required": true, "schema": { "type": "integer", - "format": "int32" + "format": "int64" } } ], @@ -1507,14 +1720,14 @@ "content": { "application/vnd.api+json": { "schema": { - "$ref": "#/components/schemas/to-many-tag-request-data" + "$ref": "#/components/schemas/toManyTagInRequest" } } } }, "responses": { "204": { - "description": "Success" + "description": "No Content" } } } @@ -1542,7 +1755,7 @@ }, "meta": { "type": "object", - "additionalProperties": {} + "additionalProperties": { } } }, "additionalProperties": false @@ -1555,9 +1768,11 @@ "type": "object", "properties": { "self": { + "minLength": 1, "type": "string" }, "related": { + "minLength": 1, "type": "string" } }, @@ -1571,12 +1786,14 @@ "type": "object", "properties": { "self": { + "minLength": 1, "type": "string" }, "describedby": { "type": "string" }, "first": { + "minLength": 1, "type": "string" }, "last": { @@ -1598,6 +1815,7 @@ "type": "object", "properties": { "self": { + "minLength": 1, "type": "string" }, "describedby": { @@ -1615,15 +1833,18 @@ "type": "object", "properties": { "self": { + "minLength": 1, "type": "string" }, "describedby": { "type": "string" }, "related": { + "minLength": 1, "type": "string" }, "first": { + "minLength": 1, "type": "string" }, "last": { @@ -1646,12 +1867,14 @@ "type": "object", "properties": { "self": { + "minLength": 1, "type": "string" }, "describedby": { "type": "string" }, "related": { + "minLength": 1, "type": "string" } }, @@ -1664,12 +1887,13 @@ "type": "object", "properties": { "self": { + "minLength": 1, "type": "string" } }, "additionalProperties": false }, - "null-value": { + "nullValue": { "not": { "anyOf": [ { @@ -1688,17 +1912,116 @@ "type": "array" } ], - "items": {} + "items": { } }, "nullable": true }, - "people-resource-type": { - "enum": [ - "people" + "nullablePersonIdentifierResponseDocument": { + "required": [ + "data", + "links" ], - "type": "string" + "type": "object", + "properties": { + "data": { + "oneOf": [ + { + "$ref": "#/components/schemas/personIdentifier" + }, + { + "$ref": "#/components/schemas/nullValue" + } + ] + }, + "meta": { + "type": "object", + "additionalProperties": { } + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapiObject" + }, + "links": { + "$ref": "#/components/schemas/linksInResourceIdentifierDocument" + } + }, + "additionalProperties": false }, - "person-attributes-in-patch-request": { + "nullablePersonSecondaryResponseDocument": { + "required": [ + "data", + "links" + ], + "type": "object", + "properties": { + "data": { + "oneOf": [ + { + "$ref": "#/components/schemas/personDataInResponse" + }, + { + "$ref": "#/components/schemas/nullValue" + } + ] + }, + "meta": { + "type": "object", + "additionalProperties": { } + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapiObject" + }, + "links": { + "$ref": "#/components/schemas/linksInResourceDocument" + } + }, + "additionalProperties": false + }, + "nullableToOnePersonInRequest": { + "required": [ + "data" + ], + "type": "object", + "properties": { + "data": { + "oneOf": [ + { + "$ref": "#/components/schemas/personIdentifier" + }, + { + "$ref": "#/components/schemas/nullValue" + } + ] + } + }, + "additionalProperties": false + }, + "nullableToOnePersonInResponse": { + "required": [ + "links" + ], + "type": "object", + "properties": { + "data": { + "oneOf": [ + { + "$ref": "#/components/schemas/personIdentifier" + }, + { + "$ref": "#/components/schemas/nullValue" + } + ] + }, + "links": { + "$ref": "#/components/schemas/linksInRelationshipObject" + }, + "meta": { + "type": "object", + "additionalProperties": { } + } + }, + "additionalProperties": false + }, + "personAttributesInPatchRequest": { "type": "object", "properties": { "firstName": { @@ -1706,13 +2029,15 @@ "nullable": true }, "lastName": { - "type": "string", - "nullable": true + "type": "string" } }, "additionalProperties": false }, - "person-attributes-in-post-request": { + "personAttributesInPostRequest": { + "required": [ + "lastName" + ], "type": "object", "properties": { "firstName": { @@ -1720,13 +2045,12 @@ "nullable": true }, "lastName": { - "type": "string", - "nullable": true + "type": "string" } }, "additionalProperties": false }, - "person-attributes-in-response": { + "personAttributesInResponse": { "type": "object", "properties": { "firstName": { @@ -1734,39 +2058,42 @@ "nullable": true }, "lastName": { + "type": "string" + }, + "displayName": { "type": "string", - "nullable": true + "readOnly": true } }, "additionalProperties": false }, - "person-collection-response-document": { + "personCollectionResponseDocument": { "required": [ "data", "links" ], "type": "object", "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/personDataInResponse" + } + }, "meta": { "type": "object", - "additionalProperties": {} + "additionalProperties": { } }, "jsonapi": { "$ref": "#/components/schemas/jsonapiObject" }, "links": { "$ref": "#/components/schemas/linksInResourceCollectionDocument" - }, - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/person-data-in-response" - } } }, "additionalProperties": false }, - "person-data-in-patch-request": { + "personDataInPatchRequest": { "required": [ "id", "type" @@ -1774,39 +2101,40 @@ "type": "object", "properties": { "type": { - "$ref": "#/components/schemas/people-resource-type" + "$ref": "#/components/schemas/personResourceType" }, "id": { + "minLength": 1, "type": "string" }, "attributes": { - "$ref": "#/components/schemas/person-attributes-in-patch-request" + "$ref": "#/components/schemas/personAttributesInPatchRequest" }, "relationships": { - "$ref": "#/components/schemas/person-relationships-in-patch-request" + "$ref": "#/components/schemas/personRelationshipsInPatchRequest" } }, "additionalProperties": false }, - "person-data-in-post-request": { + "personDataInPostRequest": { "required": [ "type" ], "type": "object", "properties": { "type": { - "$ref": "#/components/schemas/people-resource-type" + "$ref": "#/components/schemas/personResourceType" }, "attributes": { - "$ref": "#/components/schemas/person-attributes-in-post-request" + "$ref": "#/components/schemas/personAttributesInPostRequest" }, "relationships": { - "$ref": "#/components/schemas/person-relationships-in-post-request" + "$ref": "#/components/schemas/personRelationshipsInPostRequest" } }, "additionalProperties": false }, - "person-data-in-response": { + "personDataInResponse": { "required": [ "id", "links", @@ -1815,28 +2143,29 @@ "type": "object", "properties": { "type": { - "$ref": "#/components/schemas/people-resource-type" + "$ref": "#/components/schemas/personResourceType" }, "id": { + "minLength": 1, "type": "string" }, "attributes": { - "$ref": "#/components/schemas/person-attributes-in-response" + "$ref": "#/components/schemas/personAttributesInResponse" }, "relationships": { - "$ref": "#/components/schemas/person-relationships-in-response" + "$ref": "#/components/schemas/personRelationshipsInResponse" }, "links": { "$ref": "#/components/schemas/linksInResourceObject" }, "meta": { "type": "object", - "additionalProperties": {} + "additionalProperties": { } } }, "additionalProperties": false }, - "person-identifier": { + "personIdentifier": { "required": [ "id", "type" @@ -1844,149 +2173,151 @@ "type": "object", "properties": { "type": { - "$ref": "#/components/schemas/people-resource-type" + "$ref": "#/components/schemas/personResourceType" }, "id": { + "minLength": 1, "type": "string" } }, "additionalProperties": false }, - "person-identifier-response-document": { + "personIdentifierResponseDocument": { "required": [ "data", "links" ], "type": "object", "properties": { + "data": { + "$ref": "#/components/schemas/personIdentifier" + }, "meta": { "type": "object", - "additionalProperties": {} + "additionalProperties": { } }, "jsonapi": { "$ref": "#/components/schemas/jsonapiObject" }, "links": { "$ref": "#/components/schemas/linksInResourceIdentifierDocument" - }, - "data": { - "oneOf": [ - { - "$ref": "#/components/schemas/person-identifier" - }, - { - "$ref": "#/components/schemas/null-value" - } - ] } }, "additionalProperties": false }, - "person-patch-request-document": { + "personPatchRequestDocument": { "required": [ "data" ], "type": "object", "properties": { "data": { - "$ref": "#/components/schemas/person-data-in-patch-request" + "$ref": "#/components/schemas/personDataInPatchRequest" } }, "additionalProperties": false }, - "person-post-request-document": { + "personPostRequestDocument": { "required": [ "data" ], "type": "object", "properties": { "data": { - "$ref": "#/components/schemas/person-data-in-post-request" + "$ref": "#/components/schemas/personDataInPostRequest" } }, "additionalProperties": false }, - "person-primary-response-document": { + "personPrimaryResponseDocument": { "required": [ "data", "links" ], "type": "object", "properties": { + "data": { + "$ref": "#/components/schemas/personDataInResponse" + }, "meta": { "type": "object", - "additionalProperties": {} + "additionalProperties": { } }, "jsonapi": { "$ref": "#/components/schemas/jsonapiObject" }, "links": { "$ref": "#/components/schemas/linksInResourceDocument" - }, - "data": { - "$ref": "#/components/schemas/person-data-in-response" } }, "additionalProperties": false }, - "person-relationships-in-patch-request": { + "personRelationshipsInPatchRequest": { "type": "object", "properties": { + "ownedTodoItems": { + "$ref": "#/components/schemas/toManyTodoItemInRequest" + }, "assignedTodoItems": { - "$ref": "#/components/schemas/to-many-todoItem-request-data" + "$ref": "#/components/schemas/toManyTodoItemInRequest" } }, "additionalProperties": false }, - "person-relationships-in-post-request": { + "personRelationshipsInPostRequest": { "type": "object", "properties": { + "ownedTodoItems": { + "$ref": "#/components/schemas/toManyTodoItemInRequest" + }, "assignedTodoItems": { - "$ref": "#/components/schemas/to-many-todoItem-request-data" + "$ref": "#/components/schemas/toManyTodoItemInRequest" } }, "additionalProperties": false }, - "person-relationships-in-response": { + "personRelationshipsInResponse": { "type": "object", "properties": { + "ownedTodoItems": { + "$ref": "#/components/schemas/toManyTodoItemInResponse" + }, "assignedTodoItems": { - "$ref": "#/components/schemas/to-many-todoItem-response-data" + "$ref": "#/components/schemas/toManyTodoItemInResponse" } }, "additionalProperties": false }, - "person-secondary-response-document": { + "personResourceType": { + "enum": [ + "people" + ], + "type": "string" + }, + "personSecondaryResponseDocument": { "required": [ "data", "links" ], "type": "object", "properties": { + "data": { + "$ref": "#/components/schemas/personDataInResponse" + }, "meta": { "type": "object", - "additionalProperties": {} + "additionalProperties": { } }, "jsonapi": { "$ref": "#/components/schemas/jsonapiObject" }, "links": { "$ref": "#/components/schemas/linksInResourceDocument" - }, - "data": { - "oneOf": [ - { - "$ref": "#/components/schemas/person-data-in-response" - }, - { - "$ref": "#/components/schemas/null-value" - } - ] } }, "additionalProperties": false }, - "tag-attributes-in-patch-request": { + "tagAttributesInPatchRequest": { "type": "object", "properties": { "name": { @@ -1996,7 +2327,7 @@ }, "additionalProperties": false }, - "tag-attributes-in-post-request": { + "tagAttributesInPostRequest": { "required": [ "name" ], @@ -2009,7 +2340,7 @@ }, "additionalProperties": false }, - "tag-attributes-in-response": { + "tagAttributesInResponse": { "type": "object", "properties": { "name": { @@ -2019,33 +2350,33 @@ }, "additionalProperties": false }, - "tag-collection-response-document": { + "tagCollectionResponseDocument": { "required": [ "data", "links" ], "type": "object", "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/tagDataInResponse" + } + }, "meta": { "type": "object", - "additionalProperties": {} + "additionalProperties": { } }, "jsonapi": { "$ref": "#/components/schemas/jsonapiObject" }, "links": { "$ref": "#/components/schemas/linksInResourceCollectionDocument" - }, - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/tag-data-in-response" - } } }, "additionalProperties": false }, - "tag-data-in-patch-request": { + "tagDataInPatchRequest": { "required": [ "id", "type" @@ -2053,39 +2384,40 @@ "type": "object", "properties": { "type": { - "$ref": "#/components/schemas/tags-resource-type" + "$ref": "#/components/schemas/tagResourceType" }, "id": { + "minLength": 1, "type": "string" }, "attributes": { - "$ref": "#/components/schemas/tag-attributes-in-patch-request" + "$ref": "#/components/schemas/tagAttributesInPatchRequest" }, "relationships": { - "$ref": "#/components/schemas/tag-relationships-in-patch-request" + "$ref": "#/components/schemas/tagRelationshipsInPatchRequest" } }, "additionalProperties": false }, - "tag-data-in-post-request": { + "tagDataInPostRequest": { "required": [ "type" ], "type": "object", "properties": { "type": { - "$ref": "#/components/schemas/tags-resource-type" + "$ref": "#/components/schemas/tagResourceType" }, "attributes": { - "$ref": "#/components/schemas/tag-attributes-in-post-request" + "$ref": "#/components/schemas/tagAttributesInPostRequest" }, "relationships": { - "$ref": "#/components/schemas/tag-relationships-in-post-request" + "$ref": "#/components/schemas/tagRelationshipsInPostRequest" } }, "additionalProperties": false }, - "tag-data-in-response": { + "tagDataInResponse": { "required": [ "id", "links", @@ -2094,28 +2426,29 @@ "type": "object", "properties": { "type": { - "$ref": "#/components/schemas/tags-resource-type" + "$ref": "#/components/schemas/tagResourceType" }, "id": { + "minLength": 1, "type": "string" }, "attributes": { - "$ref": "#/components/schemas/tag-attributes-in-response" + "$ref": "#/components/schemas/tagAttributesInResponse" }, "relationships": { - "$ref": "#/components/schemas/tag-relationships-in-response" + "$ref": "#/components/schemas/tagRelationshipsInResponse" }, "links": { "$ref": "#/components/schemas/linksInResourceObject" }, "meta": { "type": "object", - "additionalProperties": {} + "additionalProperties": { } } }, "additionalProperties": false }, - "tag-identifier": { + "tagIdentifier": { "required": [ "id", "type" @@ -2123,121 +2456,122 @@ "type": "object", "properties": { "type": { - "$ref": "#/components/schemas/tags-resource-type" + "$ref": "#/components/schemas/tagResourceType" }, "id": { + "minLength": 1, "type": "string" } }, "additionalProperties": false }, - "tag-identifier-collection-response-document": { + "tagIdentifierCollectionResponseDocument": { "required": [ "data", "links" ], "type": "object", "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/tagIdentifier" + } + }, "meta": { "type": "object", - "additionalProperties": {} + "additionalProperties": { } }, "jsonapi": { "$ref": "#/components/schemas/jsonapiObject" }, "links": { "$ref": "#/components/schemas/linksInResourceIdentifierCollectionDocument" - }, - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/tag-identifier" - } } }, "additionalProperties": false }, - "tag-patch-request-document": { + "tagPatchRequestDocument": { "required": [ "data" ], "type": "object", "properties": { "data": { - "$ref": "#/components/schemas/tag-data-in-patch-request" + "$ref": "#/components/schemas/tagDataInPatchRequest" } }, "additionalProperties": false }, - "tag-post-request-document": { + "tagPostRequestDocument": { "required": [ "data" ], "type": "object", "properties": { "data": { - "$ref": "#/components/schemas/tag-data-in-post-request" + "$ref": "#/components/schemas/tagDataInPostRequest" } }, "additionalProperties": false }, - "tag-primary-response-document": { + "tagPrimaryResponseDocument": { "required": [ "data", "links" ], "type": "object", "properties": { + "data": { + "$ref": "#/components/schemas/tagDataInResponse" + }, "meta": { "type": "object", - "additionalProperties": {} + "additionalProperties": { } }, "jsonapi": { "$ref": "#/components/schemas/jsonapiObject" }, "links": { "$ref": "#/components/schemas/linksInResourceDocument" - }, - "data": { - "$ref": "#/components/schemas/tag-data-in-response" } }, "additionalProperties": false }, - "tag-relationships-in-patch-request": { + "tagRelationshipsInPatchRequest": { "type": "object", "properties": { "todoItems": { - "$ref": "#/components/schemas/to-many-todoItem-request-data" + "$ref": "#/components/schemas/toManyTodoItemInRequest" } }, "additionalProperties": false }, - "tag-relationships-in-post-request": { + "tagRelationshipsInPostRequest": { "type": "object", "properties": { "todoItems": { - "$ref": "#/components/schemas/to-many-todoItem-request-data" + "$ref": "#/components/schemas/toManyTodoItemInRequest" } }, "additionalProperties": false }, - "tag-relationships-in-response": { + "tagRelationshipsInResponse": { "type": "object", "properties": { "todoItems": { - "$ref": "#/components/schemas/to-many-todoItem-response-data" + "$ref": "#/components/schemas/toManyTodoItemInResponse" } }, "additionalProperties": false }, - "tags-resource-type": { + "tagResourceType": { "enum": [ "tags" ], "type": "string" }, - "to-many-tag-request-data": { + "toManyTagInRequest": { "required": [ "data" ], @@ -2246,35 +2580,35 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/tag-identifier" + "$ref": "#/components/schemas/tagIdentifier" } } }, "additionalProperties": false }, - "to-many-tag-response-data": { + "toManyTagInResponse": { "required": [ "links" ], "type": "object", "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/tagIdentifier" + } + }, "links": { "$ref": "#/components/schemas/linksInRelationshipObject" }, "meta": { "type": "object", - "additionalProperties": {} - }, - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/tag-identifier" - } + "additionalProperties": { } } }, "additionalProperties": false }, - "to-many-todoItem-request-data": { + "toManyTodoItemInRequest": { "required": [ "data" ], @@ -2283,115 +2617,117 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/todoItem-identifier" + "$ref": "#/components/schemas/todoItemIdentifier" } } }, "additionalProperties": false }, - "to-many-todoItem-response-data": { + "toManyTodoItemInResponse": { "required": [ "links" ], "type": "object", "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/todoItemIdentifier" + } + }, "links": { "$ref": "#/components/schemas/linksInRelationshipObject" }, "meta": { "type": "object", - "additionalProperties": {} - }, - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/todoItem-identifier" - } + "additionalProperties": { } } }, "additionalProperties": false }, - "to-one-person-request-data": { + "toOnePersonInRequest": { "required": [ "data" ], "type": "object", "properties": { "data": { - "oneOf": [ - { - "$ref": "#/components/schemas/person-identifier" - }, - { - "$ref": "#/components/schemas/null-value" - } - ] + "$ref": "#/components/schemas/personIdentifier" } }, "additionalProperties": false }, - "to-one-person-response-data": { + "toOnePersonInResponse": { "required": [ "links" ], "type": "object", "properties": { + "data": { + "$ref": "#/components/schemas/personIdentifier" + }, "links": { "$ref": "#/components/schemas/linksInRelationshipObject" }, "meta": { "type": "object", - "additionalProperties": {} - }, - "data": { - "oneOf": [ - { - "$ref": "#/components/schemas/person-identifier" - }, - { - "$ref": "#/components/schemas/null-value" - } - ] + "additionalProperties": { } } }, "additionalProperties": false }, - "todoItem-attributes-in-patch-request": { + "todoItemAttributesInPatchRequest": { "type": "object", "properties": { "description": { - "type": "string", - "nullable": true + "type": "string" }, "priority": { "$ref": "#/components/schemas/todoItemPriority" + }, + "durationInHours": { + "type": "integer", + "format": "int64", + "nullable": true } }, "additionalProperties": false }, - "todoItem-attributes-in-post-request": { + "todoItemAttributesInPostRequest": { + "required": [ + "description", + "priority" + ], "type": "object", "properties": { "description": { - "type": "string", - "nullable": true + "type": "string" }, "priority": { "$ref": "#/components/schemas/todoItemPriority" + }, + "durationInHours": { + "type": "integer", + "format": "int64", + "nullable": true } }, "additionalProperties": false }, - "todoItem-attributes-in-response": { + "todoItemAttributesInResponse": { "type": "object", "properties": { "description": { - "type": "string", - "nullable": true + "type": "string" }, "priority": { "$ref": "#/components/schemas/todoItemPriority" }, + "durationInHours": { + "type": "integer", + "format": "int64", + "nullable": true + }, "createdAt": { "type": "string", "format": "date-time" @@ -2404,33 +2740,33 @@ }, "additionalProperties": false }, - "todoItem-collection-response-document": { + "todoItemCollectionResponseDocument": { "required": [ "data", "links" ], "type": "object", "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/todoItemDataInResponse" + } + }, "meta": { "type": "object", - "additionalProperties": {} + "additionalProperties": { } }, "jsonapi": { "$ref": "#/components/schemas/jsonapiObject" }, "links": { "$ref": "#/components/schemas/linksInResourceCollectionDocument" - }, - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/todoItem-data-in-response" - } } }, "additionalProperties": false }, - "todoItem-data-in-patch-request": { + "todoItemDataInPatchRequest": { "required": [ "id", "type" @@ -2438,39 +2774,40 @@ "type": "object", "properties": { "type": { - "$ref": "#/components/schemas/todoItems-resource-type" + "$ref": "#/components/schemas/todoItemResourceType" }, "id": { + "minLength": 1, "type": "string" }, "attributes": { - "$ref": "#/components/schemas/todoItem-attributes-in-patch-request" + "$ref": "#/components/schemas/todoItemAttributesInPatchRequest" }, "relationships": { - "$ref": "#/components/schemas/todoItem-relationships-in-patch-request" + "$ref": "#/components/schemas/todoItemRelationshipsInPatchRequest" } }, "additionalProperties": false }, - "todoItem-data-in-post-request": { + "todoItemDataInPostRequest": { "required": [ "type" ], "type": "object", "properties": { "type": { - "$ref": "#/components/schemas/todoItems-resource-type" + "$ref": "#/components/schemas/todoItemResourceType" }, "attributes": { - "$ref": "#/components/schemas/todoItem-attributes-in-post-request" + "$ref": "#/components/schemas/todoItemAttributesInPostRequest" }, "relationships": { - "$ref": "#/components/schemas/todoItem-relationships-in-post-request" + "$ref": "#/components/schemas/todoItemRelationshipsInPostRequest" } }, "additionalProperties": false }, - "todoItem-data-in-response": { + "todoItemDataInResponse": { "required": [ "id", "links", @@ -2479,28 +2816,29 @@ "type": "object", "properties": { "type": { - "$ref": "#/components/schemas/todoItems-resource-type" + "$ref": "#/components/schemas/todoItemResourceType" }, "id": { + "minLength": 1, "type": "string" }, "attributes": { - "$ref": "#/components/schemas/todoItem-attributes-in-response" + "$ref": "#/components/schemas/todoItemAttributesInResponse" }, "relationships": { - "$ref": "#/components/schemas/todoItem-relationships-in-response" + "$ref": "#/components/schemas/todoItemRelationshipsInResponse" }, "links": { "$ref": "#/components/schemas/linksInResourceObject" }, "meta": { "type": "object", - "additionalProperties": {} + "additionalProperties": { } } }, "additionalProperties": false }, - "todoItem-identifier": { + "todoItemIdentifier": { "required": [ "id", "type" @@ -2508,141 +2846,145 @@ "type": "object", "properties": { "type": { - "$ref": "#/components/schemas/todoItems-resource-type" + "$ref": "#/components/schemas/todoItemResourceType" }, "id": { + "minLength": 1, "type": "string" } }, "additionalProperties": false }, - "todoItem-identifier-collection-response-document": { + "todoItemIdentifierCollectionResponseDocument": { "required": [ "data", "links" ], "type": "object", "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/todoItemIdentifier" + } + }, "meta": { "type": "object", - "additionalProperties": {} + "additionalProperties": { } }, "jsonapi": { "$ref": "#/components/schemas/jsonapiObject" }, "links": { "$ref": "#/components/schemas/linksInResourceIdentifierCollectionDocument" - }, - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/todoItem-identifier" - } } }, "additionalProperties": false }, - "todoItem-patch-request-document": { + "todoItemPatchRequestDocument": { "required": [ "data" ], "type": "object", "properties": { "data": { - "$ref": "#/components/schemas/todoItem-data-in-patch-request" + "$ref": "#/components/schemas/todoItemDataInPatchRequest" } }, "additionalProperties": false }, - "todoItem-post-request-document": { + "todoItemPostRequestDocument": { "required": [ "data" ], "type": "object", "properties": { "data": { - "$ref": "#/components/schemas/todoItem-data-in-post-request" + "$ref": "#/components/schemas/todoItemDataInPostRequest" } }, "additionalProperties": false }, - "todoItem-primary-response-document": { + "todoItemPrimaryResponseDocument": { "required": [ "data", "links" ], "type": "object", "properties": { + "data": { + "$ref": "#/components/schemas/todoItemDataInResponse" + }, "meta": { "type": "object", - "additionalProperties": {} + "additionalProperties": { } }, "jsonapi": { "$ref": "#/components/schemas/jsonapiObject" }, "links": { "$ref": "#/components/schemas/linksInResourceDocument" - }, - "data": { - "$ref": "#/components/schemas/todoItem-data-in-response" } }, "additionalProperties": false }, - "todoItem-relationships-in-patch-request": { + "todoItemPriority": { + "enum": [ + "High", + "Medium", + "Low" + ], + "type": "string" + }, + "todoItemRelationshipsInPatchRequest": { "type": "object", "properties": { "owner": { - "$ref": "#/components/schemas/to-one-person-request-data" + "$ref": "#/components/schemas/toOnePersonInRequest" }, "assignee": { - "$ref": "#/components/schemas/to-one-person-request-data" + "$ref": "#/components/schemas/nullableToOnePersonInRequest" }, "tags": { - "$ref": "#/components/schemas/to-many-tag-request-data" + "$ref": "#/components/schemas/toManyTagInRequest" } }, "additionalProperties": false }, - "todoItem-relationships-in-post-request": { + "todoItemRelationshipsInPostRequest": { + "required": [ + "owner" + ], "type": "object", "properties": { "owner": { - "$ref": "#/components/schemas/to-one-person-request-data" + "$ref": "#/components/schemas/toOnePersonInRequest" }, "assignee": { - "$ref": "#/components/schemas/to-one-person-request-data" + "$ref": "#/components/schemas/nullableToOnePersonInRequest" }, "tags": { - "$ref": "#/components/schemas/to-many-tag-request-data" + "$ref": "#/components/schemas/toManyTagInRequest" } }, "additionalProperties": false }, - "todoItem-relationships-in-response": { + "todoItemRelationshipsInResponse": { "type": "object", "properties": { "owner": { - "$ref": "#/components/schemas/to-one-person-response-data" + "$ref": "#/components/schemas/toOnePersonInResponse" }, "assignee": { - "$ref": "#/components/schemas/to-one-person-response-data" + "$ref": "#/components/schemas/nullableToOnePersonInResponse" }, "tags": { - "$ref": "#/components/schemas/to-many-tag-response-data" + "$ref": "#/components/schemas/toManyTagInResponse" } }, "additionalProperties": false }, - "todoItemPriority": { - "enum": [ - "Low", - "Medium", - "High" - ], - "type": "string" - }, - "todoItems-resource-type": { + "todoItemResourceType": { "enum": [ "todoItems" ], @@ -2650,4 +2992,4 @@ } } } -} +} \ No newline at end of file diff --git a/src/Examples/JsonApiDotNetCoreExampleClient/Program.cs b/src/Examples/JsonApiDotNetCoreExampleClient/Program.cs index 3218889f4f..4ca5e5f58d 100644 --- a/src/Examples/JsonApiDotNetCoreExampleClient/Program.cs +++ b/src/Examples/JsonApiDotNetCoreExampleClient/Program.cs @@ -13,7 +13,7 @@ private static async Task Main() try { const int nonExistingId = int.MaxValue; - await exampleApiClient.Delete_personAsync(nonExistingId); + await exampleApiClient.DeletePersonAsync(nonExistingId); } catch (ApiException exception) { diff --git a/src/Examples/MultiDbContextExample/Program.cs b/src/Examples/MultiDbContextExample/Program.cs index f8a99654de..a8acd7ae83 100644 --- a/src/Examples/MultiDbContextExample/Program.cs +++ b/src/Examples/MultiDbContextExample/Program.cs @@ -1,4 +1,7 @@ +using System.Diagnostics; using JsonApiDotNetCore.Configuration; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using MultiDbContextExample.Data; using MultiDbContextExample.Models; using MultiDbContextExample.Repositories; @@ -7,13 +10,29 @@ // Add services to the container. -builder.Services.AddSqlite("Data Source=A.db;Pooling=False"); -builder.Services.AddSqlite("Data Source=B.db;Pooling=False"); +builder.Services.AddDbContext(options => +{ + options.UseSqlite("Data Source=SampleDbA.db;Pooling=False"); + SetDbContextDebugOptions(options); +}); + +builder.Services.AddDbContext(options => +{ + options.UseSqlite("Data Source=SampleDbB.db;Pooling=False"); + SetDbContextDebugOptions(options); +}); builder.Services.AddJsonApi(options => { + options.Namespace = "api"; + options.UseRelativeLinks = true; + options.IncludeTotalResourceCount = true; + +#if DEBUG options.IncludeExceptionStackTraceInErrors = true; options.IncludeRequestBodyInErrors = true; + options.SerializerOptions.WriteIndented = true; +#endif }, dbContextTypes: new[] { typeof(DbContextA), @@ -35,6 +54,14 @@ app.Run(); +[Conditional("DEBUG")] +static void SetDbContextDebugOptions(DbContextOptionsBuilder options) +{ + options.EnableDetailedErrors(); + options.EnableSensitiveDataLogging(); + options.ConfigureWarnings(builder => builder.Ignore(CoreEventId.SensitiveDataLoggingEnabledWarning)); +} + static async Task CreateDatabaseAsync(IServiceProvider serviceProvider) { await using AsyncServiceScope scope = serviceProvider.CreateAsyncScope(); diff --git a/src/Examples/MultiDbContextExample/Properties/launchSettings.json b/src/Examples/MultiDbContextExample/Properties/launchSettings.json index a77f78562b..9d3467265f 100644 --- a/src/Examples/MultiDbContextExample/Properties/launchSettings.json +++ b/src/Examples/MultiDbContextExample/Properties/launchSettings.json @@ -12,7 +12,7 @@ "IIS Express": { "commandName": "IISExpress", "launchBrowser": true, - "launchUrl": "resourceBs", + "launchUrl": "api/resourceBs", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -20,7 +20,7 @@ "Kestrel": { "commandName": "Project", "launchBrowser": true, - "launchUrl": "resourceBs", + "launchUrl": "api/resourceBs", "applicationUrl": "https://localhost:44350;http://localhost:14150", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/src/Examples/MultiDbContextExample/appsettings.json b/src/Examples/MultiDbContextExample/appsettings.json index d0229a3016..590851ee61 100644 --- a/src/Examples/MultiDbContextExample/appsettings.json +++ b/src/Examples/MultiDbContextExample/appsettings.json @@ -2,8 +2,10 @@ "Logging": { "LogLevel": { "Default": "Warning", - "Microsoft.Hosting.Lifetime": "Warning", - "Microsoft.EntityFrameworkCore.Database.Command": "Critical" + // Include server startup, incoming requests and SQL commands. + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.AspNetCore.Hosting.Diagnostics": "Information", + "Microsoft.EntityFrameworkCore.Database.Command": "Information" } }, "AllowedHosts": "*" diff --git a/src/Examples/NoEntityFrameworkExample/Data/AppDbContext.cs b/src/Examples/NoEntityFrameworkExample/Data/AppDbContext.cs deleted file mode 100644 index c10cda8e6c..0000000000 --- a/src/Examples/NoEntityFrameworkExample/Data/AppDbContext.cs +++ /dev/null @@ -1,16 +0,0 @@ -using JetBrains.Annotations; -using Microsoft.EntityFrameworkCore; -using NoEntityFrameworkExample.Models; - -namespace NoEntityFrameworkExample.Data; - -[UsedImplicitly(ImplicitUseTargetFlags.Members)] -public sealed class AppDbContext : DbContext -{ - public DbSet WorkItems => Set(); - - public AppDbContext(DbContextOptions options) - : base(options) - { - } -} diff --git a/src/Examples/NoEntityFrameworkExample/Data/Database.cs b/src/Examples/NoEntityFrameworkExample/Data/Database.cs new file mode 100644 index 0000000000..eee64653ee --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/Data/Database.cs @@ -0,0 +1,131 @@ +using JetBrains.Annotations; +using NoEntityFrameworkExample.Models; + +namespace NoEntityFrameworkExample.Data; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +internal sealed class Database +{ + public static List TodoItems { get; } + public static List Tags { get; } + public static List People { get; } + + static Database() + { + int personIndex = 0; + int tagIndex = 0; + int todoItemIndex = 0; + + var john = new Person + { + Id = ++personIndex, + FirstName = "John", + LastName = "Doe" + }; + + var jane = new Person + { + Id = ++personIndex, + FirstName = "Jane", + LastName = "Doe" + }; + + var personalTag = new Tag + { + Id = ++tagIndex, + Name = "Personal" + }; + + var familyTag = new Tag + { + Id = ++tagIndex, + Name = "Family" + }; + + var businessTag = new Tag + { + Id = ++tagIndex, + Name = "Business" + }; + + TodoItems = new List + { + new() + { + Id = ++todoItemIndex, + Description = "Make homework", + DurationInHours = 3, + Priority = TodoItemPriority.High, + Owner = john, + Assignee = jane, + Tags = + { + personalTag + } + }, + new() + { + Id = ++todoItemIndex, + Description = "Book vacation", + DurationInHours = 2, + Priority = TodoItemPriority.Low, + Owner = jane, + Tags = + { + personalTag + } + }, + new() + { + Id = ++todoItemIndex, + Description = "Cook dinner", + DurationInHours = 1, + Priority = TodoItemPriority.Medium, + Owner = jane, + Assignee = john, + Tags = + { + familyTag, + personalTag + } + }, + new() + { + Id = ++todoItemIndex, + Description = "Check emails", + DurationInHours = 1, + Priority = TodoItemPriority.Low, + Owner = john, + Assignee = john, + Tags = + { + businessTag + } + } + }; + + Tags = new List + { + personalTag, + familyTag, + businessTag + }; + + People = new List + { + john, + jane + }; + + foreach (Tag tag in Tags) + { + tag.TodoItems = TodoItems.Where(todoItem => todoItem.Tags.Any(tagInTodoItem => tagInTodoItem.Id == tag.Id)).ToHashSet(); + } + + foreach (Person person in People) + { + person.OwnedTodoItems = TodoItems.Where(todoItem => todoItem.Owner == person).ToHashSet(); + person.AssignedTodoItems = TodoItems.Where(todoItem => todoItem.Assignee == person).ToHashSet(); + } + } +} diff --git a/src/Examples/NoEntityFrameworkExample/Data/InMemoryModel.cs b/src/Examples/NoEntityFrameworkExample/Data/InMemoryModel.cs new file mode 100644 index 0000000000..c81aa07b8f --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/Data/InMemoryModel.cs @@ -0,0 +1,25 @@ +using System.Reflection; +using JsonApiDotNetCore.Configuration; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace NoEntityFrameworkExample.Data; + +internal sealed class InMemoryModel : RuntimeModel +{ + public InMemoryModel(IResourceGraph resourceGraph) + { + foreach (ResourceType resourceType in resourceGraph.GetResourceTypes()) + { + RuntimeEntityType entityType = AddEntityType(resourceType.ClrType.FullName!, resourceType.ClrType); + SetEntityProperties(entityType, resourceType); + } + } + + private static void SetEntityProperties(RuntimeEntityType entityType, ResourceType resourceType) + { + foreach (PropertyInfo property in resourceType.ClrType.GetProperties()) + { + entityType.AddProperty(property.Name, property.PropertyType, property); + } + } +} diff --git a/src/Examples/NoEntityFrameworkExample/InMemoryInverseNavigationResolver.cs b/src/Examples/NoEntityFrameworkExample/InMemoryInverseNavigationResolver.cs new file mode 100644 index 0000000000..179eafd516 --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/InMemoryInverseNavigationResolver.cs @@ -0,0 +1,41 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources.Annotations; +using NoEntityFrameworkExample.Models; + +namespace NoEntityFrameworkExample; + +internal sealed class InMemoryInverseNavigationResolver : IInverseNavigationResolver +{ + private readonly IResourceGraph _resourceGraph; + + public InMemoryInverseNavigationResolver(IResourceGraph resourceGraph) + { + _resourceGraph = resourceGraph; + } + + /// + public void Resolve() + { + ResourceType todoItemType = _resourceGraph.GetResourceType(); + RelationshipAttribute todoItemOwnerRelationship = todoItemType.GetRelationshipByPropertyName(nameof(TodoItem.Owner)); + RelationshipAttribute todoItemAssigneeRelationship = todoItemType.GetRelationshipByPropertyName(nameof(TodoItem.Assignee)); + RelationshipAttribute todoItemTagsRelationship = todoItemType.GetRelationshipByPropertyName(nameof(TodoItem.Tags)); + + ResourceType personType = _resourceGraph.GetResourceType(); + RelationshipAttribute personOwnedTodoItemsRelationship = personType.GetRelationshipByPropertyName(nameof(Person.OwnedTodoItems)); + RelationshipAttribute personAssignedTodoItemsRelationship = personType.GetRelationshipByPropertyName(nameof(Person.AssignedTodoItems)); + + ResourceType tagType = _resourceGraph.GetResourceType(); + RelationshipAttribute tagTodoItemsRelationship = tagType.GetRelationshipByPropertyName(nameof(Tag.TodoItems)); + + // Inverse navigations are required for pagination on non-primary endpoints. + todoItemOwnerRelationship.InverseNavigationProperty = personOwnedTodoItemsRelationship.Property; + todoItemAssigneeRelationship.InverseNavigationProperty = personAssignedTodoItemsRelationship.Property; + todoItemTagsRelationship.InverseNavigationProperty = tagTodoItemsRelationship.Property; + + personOwnedTodoItemsRelationship.InverseNavigationProperty = todoItemOwnerRelationship.Property; + personAssignedTodoItemsRelationship.InverseNavigationProperty = todoItemAssigneeRelationship.Property; + + tagTodoItemsRelationship.InverseNavigationProperty = todoItemTagsRelationship.Property; + } +} diff --git a/src/Examples/NoEntityFrameworkExample/Models/Person.cs b/src/Examples/NoEntityFrameworkExample/Models/Person.cs new file mode 100644 index 0000000000..47a7f4da9a --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/Models/Person.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations.Schema; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace NoEntityFrameworkExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource] +public sealed class Person : Identifiable +{ + [Attr] + public string? FirstName { get; set; } + + [Attr] + public string LastName { get; set; } = null!; + + [Attr(Capabilities = AttrCapabilities.AllowView)] + [NotMapped] + public string DisplayName => FirstName != null ? $"{FirstName} {LastName}" : LastName; + + [HasMany] + public ISet OwnedTodoItems { get; set; } = new HashSet(); + + [HasMany] + public ISet AssignedTodoItems { get; set; } = new HashSet(); +} diff --git a/src/Examples/NoEntityFrameworkExample/Models/Tag.cs b/src/Examples/NoEntityFrameworkExample/Models/Tag.cs new file mode 100644 index 0000000000..425fe0923f --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/Models/Tag.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace NoEntityFrameworkExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource] +public sealed class Tag : Identifiable +{ + [Attr] + [MinLength(1)] + public string Name { get; set; } = null!; + + [HasMany] + public ISet TodoItems { get; set; } = new HashSet(); +} diff --git a/src/Examples/NoEntityFrameworkExample/Models/TodoItem.cs b/src/Examples/NoEntityFrameworkExample/Models/TodoItem.cs new file mode 100644 index 0000000000..75d948ca7c --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/Models/TodoItem.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace NoEntityFrameworkExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(GenerateControllerEndpoints = JsonApiEndpoints.Query)] +public sealed class TodoItem : Identifiable +{ + [Attr] + public string Description { get; set; } = null!; + + [Attr] + [Required] + public TodoItemPriority? Priority { get; set; } + + [Attr] + public long? DurationInHours { get; set; } + + [HasOne] + public Person Owner { get; set; } = null!; + + [HasOne] + public Person? Assignee { get; set; } + + [HasMany] + public ISet Tags { get; set; } = new HashSet(); +} diff --git a/src/Examples/NoEntityFrameworkExample/Models/TodoItemPriority.cs b/src/Examples/NoEntityFrameworkExample/Models/TodoItemPriority.cs new file mode 100644 index 0000000000..7dfd01f570 --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/Models/TodoItemPriority.cs @@ -0,0 +1,11 @@ +using JetBrains.Annotations; + +namespace NoEntityFrameworkExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public enum TodoItemPriority +{ + High = 1, + Medium = 2, + Low = 3 +} diff --git a/src/Examples/NoEntityFrameworkExample/Models/WorkItem.cs b/src/Examples/NoEntityFrameworkExample/Models/WorkItem.cs deleted file mode 100644 index 43bf1f422e..0000000000 --- a/src/Examples/NoEntityFrameworkExample/Models/WorkItem.cs +++ /dev/null @@ -1,22 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace NoEntityFrameworkExample.Models; - -[UsedImplicitly(ImplicitUseTargetFlags.Members)] -[Resource] -public sealed class WorkItem : Identifiable -{ - [Attr] - public bool IsBlocked { get; set; } - - [Attr] - public string Title { get; set; } = null!; - - [Attr] - public long DurationInHours { get; set; } - - [Attr] - public Guid ProjectId { get; set; } = Guid.NewGuid(); -} diff --git a/src/Examples/NoEntityFrameworkExample/NoEntityFrameworkExample.csproj b/src/Examples/NoEntityFrameworkExample/NoEntityFrameworkExample.csproj index 358d82c02f..9f0037b058 100644 --- a/src/Examples/NoEntityFrameworkExample/NoEntityFrameworkExample.csproj +++ b/src/Examples/NoEntityFrameworkExample/NoEntityFrameworkExample.csproj @@ -10,8 +10,6 @@ - - - + diff --git a/src/Examples/NoEntityFrameworkExample/NullSafeExpressionRewriter.cs b/src/Examples/NoEntityFrameworkExample/NullSafeExpressionRewriter.cs new file mode 100644 index 0000000000..35b2a29e9e --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/NullSafeExpressionRewriter.cs @@ -0,0 +1,316 @@ +using System.Linq.Expressions; +using System.Reflection; + +namespace NoEntityFrameworkExample; + +/// +/// Inserts a null check on member dereference and extension method invocation, to prevent a from being thrown when +/// the expression is compiled and executed. +/// +/// For example, +/// todoItem.Assignee.Id == todoItem.Owner.Id) +/// ]]> +/// would throw if the database contains a +/// TodoItem that doesn't have an assignee. +/// +public sealed class NullSafeExpressionRewriter : ExpressionVisitor +{ + private const string MinValueName = nameof(long.MinValue); + private static readonly ConstantExpression Int32MinValueConstant = Expression.Constant(int.MinValue, typeof(int)); + + private static readonly ExpressionType[] ComparisonExpressionTypes = + { + ExpressionType.LessThan, + ExpressionType.LessThanOrEqual, + ExpressionType.GreaterThan, + ExpressionType.GreaterThanOrEqual, + ExpressionType.Equal + // ExpressionType.NotEqual is excluded because WhereClauseBuilder never produces that. + }; + + private readonly Stack _callStack = new(); + + public TExpression Rewrite(TExpression expression) + where TExpression : Expression + { + _callStack.Clear(); + + return (TExpression)Visit(expression); + } + + protected override Expression VisitMethodCall(MethodCallExpression node) + { + if (node.Method.Name == "Where") + { + _callStack.Push(MethodType.Where); + Expression expression = base.VisitMethodCall(node); + _callStack.Pop(); + return expression; + } + + if (node.Method.Name is "OrderBy" or "OrderByDescending" or "ThenBy" or "ThenByDescending") + { + // Ordering can be improved by expanding into multiple OrderBy/ThenBy() calls, as described at + // https://stackoverflow.com/questions/26186527/linq-order-by-descending-with-null-values-on-bottom/26186585#26186585. + // For example: + // .OrderBy(element => element.First.Second.CharValue) + // Could be translated to: + // .OrderBy(element => element.First != null) + // .ThenBy(element => element.First == null ? false : element.First.Second != null) + // .ThenBy(element => element.First == null ? '\0' : element.First.Second == null ? '\0' : element.First.Second.CharValue) + // Which correctly orders 'element.First == null' before 'element.First.Second == null'. + // The current implementation translates to: + // .OrderBy(element => element.First == null ? '\0' : element.First.Second == null ? '\0' : element.First.Second.CharValue) + // in which the order of these two rows is undeterministic. + + _callStack.Push(MethodType.Ordering); + Expression expression = base.VisitMethodCall(node); + _callStack.Pop(); + return expression; + } + + if (_callStack.Count > 0) + { + MethodType outerMethodType = _callStack.Peek(); + + if (outerMethodType == MethodType.Ordering && node.Method.Name == "Count") + { + return ToNullSafeCountInvocationInOrderBy(node); + } + + if (outerMethodType == MethodType.Where && node.Method.Name == "Any") + { + return ToNullSafeAnyInvocationInWhere(node); + } + } + + return base.VisitMethodCall(node); + } + + private static Expression ToNullSafeCountInvocationInOrderBy(MethodCallExpression countMethodCall) + { + Expression thisArgument = countMethodCall.Arguments.Single(); + + if (thisArgument is MemberExpression memberArgument) + { + // OrderClauseBuilder never produces nested Count() calls. + + // SRC: some.Other.Children.Count() + // DST: some.Other == null ? int.MinValue : some.Other.Children == null ? int.MinValue : some.Other.Children.Count() + return ToConditionalMemberAccessInOrderBy(countMethodCall, memberArgument, Int32MinValueConstant); + } + + return countMethodCall; + } + + private static Expression ToConditionalMemberAccessInOrderBy(Expression outer, MemberExpression innerMember, ConstantExpression defaultValue) + { + MemberExpression? currentMember = innerMember; + Expression result = outer; + + do + { + // Static property/field invocations can never be null (though unlikely we'll ever encounter those). + if (!IsStaticMemberAccess(currentMember)) + { + // SRC: first.Second.StringValue + // DST: first.Second == null ? null : first.Second.StringValue + ConstantExpression nullConstant = Expression.Constant(null, currentMember.Type); + BinaryExpression isNull = Expression.Equal(currentMember, nullConstant); + result = Expression.Condition(isNull, defaultValue, result); + } + + currentMember = currentMember.Expression as MemberExpression; + } + while (currentMember != null); + + return result; + } + + private static bool IsStaticMemberAccess(MemberExpression member) + { + if (member.Member is FieldInfo field) + { + return field.IsStatic; + } + + if (member.Member is PropertyInfo property) + { + MethodInfo? getter = property.GetGetMethod(); + return getter != null && getter.IsStatic; + } + + return false; + } + + private Expression ToNullSafeAnyInvocationInWhere(MethodCallExpression anyMethodCall) + { + Expression thisArgument = anyMethodCall.Arguments.First(); + + if (thisArgument is MemberExpression memberArgument) + { + MethodCallExpression newAnyMethodCall = anyMethodCall; + + if (anyMethodCall.Arguments.Count > 1) + { + // SRC: .Any(first => first.Second.Value == 1) + // DST: .Any(first => first != null && first.Second != null && first.Second.Value == 1) + List newArguments = anyMethodCall.Arguments.Skip(1).Select(Visit).Cast().ToList(); + newArguments.Insert(0, thisArgument); + + newAnyMethodCall = anyMethodCall.Update(anyMethodCall.Object, newArguments); + } + + // SRC: some.Other.Any() + // DST: some != null && some.Other != null && some.Other.Any() + return ToConditionalMemberAccessInBooleanExpression(newAnyMethodCall, memberArgument, false); + } + + return anyMethodCall; + } + + private static Expression ToConditionalMemberAccessInBooleanExpression(Expression outer, MemberExpression innerMember, bool skipNullCheckOnLastAccess) + { + MemberExpression? currentMember = innerMember; + Expression result = outer; + + do + { + // Null-check the last member access in the chain on extension method invocation. For example: a.b.c.Count() requires a null-check on 'c'. + // This is unneeded for boolean comparisons. For example: a.b.c == d does not require a null-check on 'c'. + if (!skipNullCheckOnLastAccess || currentMember != innerMember) + { + // Static property/field invocations can never be null (though unlikely we'll ever encounter those). + if (!IsStaticMemberAccess(currentMember)) + { + // SRC: first.Second.Value == 1 + // DST: first.Second != null && first.Second.Value == 1 + ConstantExpression nullConstant = Expression.Constant(null, currentMember.Type); + BinaryExpression isNotNull = Expression.NotEqual(currentMember, nullConstant); + result = Expression.AndAlso(isNotNull, result); + } + } + + // Do not null-check the first member access in the chain, because that's the lambda parameter itself. + // For example, in: item => item.First.Second, 'item' does not require a null-check. + currentMember = currentMember.Expression as MemberExpression; + } + while (currentMember != null); + + return result; + } + + protected override Expression VisitBinary(BinaryExpression node) + { + if (_callStack.Count > 0 && _callStack.Peek() == MethodType.Where) + { + if (ComparisonExpressionTypes.Contains(node.NodeType)) + { + Expression result = node; + + result = ToNullSafeTermInBinary(node.Right, result); + result = ToNullSafeTermInBinary(node.Left, result); + + return result; + } + } + + return base.VisitBinary(node); + } + + private static Expression ToNullSafeTermInBinary(Expression binaryTerm, Expression result) + { + if (binaryTerm is MemberExpression rightMember) + { + // SRC: some.Other.Value == 1 + // DST: some != null && some.Other != null && some.Other.Value == 1 + return ToConditionalMemberAccessInBooleanExpression(result, rightMember, true); + } + + if (binaryTerm is MethodCallExpression { Method.Name: "Count" } countMethodCall) + { + Expression thisArgument = countMethodCall.Arguments.Single(); + + if (thisArgument is MemberExpression memberArgument) + { + // SRC: some.Other.Count() == 1 + // DST: some != null && some.Other != null && some.Other.Count() == 1 + return ToConditionalMemberAccessInBooleanExpression(result, memberArgument, false); + } + } + + return result; + } + + protected override Expression VisitMember(MemberExpression node) + { + if (_callStack.Count > 0 && _callStack.Peek() == MethodType.Ordering) + { + if (node.Expression is MemberExpression innerMember) + { + ConstantExpression defaultValue = CreateConstantForMemberIsNull(node.Type); + return ToConditionalMemberAccessInOrderBy(node, innerMember, defaultValue); + } + + return node; + } + + return base.VisitMember(node); + } + + private static ConstantExpression CreateConstantForMemberIsNull(Type type) + { + bool canContainNull = !type.IsValueType || Nullable.GetUnderlyingType(type) != null; + + if (canContainNull) + { + return Expression.Constant(null, type); + } + + Type innerType = Nullable.GetUnderlyingType(type) ?? type; + ConstantExpression? constant = TryCreateConstantForStaticMinValue(innerType); + + if (constant != null) + { + return constant; + } + + object? defaultValue = Activator.CreateInstance(type); + return Expression.Constant(defaultValue, type); + } + + private static ConstantExpression? TryCreateConstantForStaticMinValue(Type type) + { + // Int32.MinValue is a field, while Int128.MinValue is a property. + + FieldInfo? field = type.GetField(MinValueName, BindingFlags.Public | BindingFlags.Static); + + if (field != null) + { + object? value = field.GetValue(null); + return Expression.Constant(value, type); + } + + PropertyInfo? property = type.GetProperty(MinValueName, BindingFlags.Public | BindingFlags.Static); + + if (property != null) + { + MethodInfo? getter = property.GetGetMethod(); + + if (getter != null) + { + object? value = getter.Invoke(null, Array.Empty()); + return Expression.Constant(value, type); + } + } + + return null; + } + + private enum MethodType + { + Where, + Ordering + } +} diff --git a/src/Examples/NoEntityFrameworkExample/Program.cs b/src/Examples/NoEntityFrameworkExample/Program.cs index 363b58b19e..8b299e2c24 100755 --- a/src/Examples/NoEntityFrameworkExample/Program.cs +++ b/src/Examples/NoEntityFrameworkExample/Program.cs @@ -1,18 +1,24 @@ using JsonApiDotNetCore.Configuration; -using NoEntityFrameworkExample.Data; -using NoEntityFrameworkExample.Models; -using NoEntityFrameworkExample.Services; +using NoEntityFrameworkExample; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); // Add services to the container. -string? connectionString = GetConnectionString(builder.Configuration); -builder.Services.AddNpgsql(connectionString); +builder.Services.AddJsonApi(options => +{ + options.Namespace = "api"; + options.UseRelativeLinks = true; + options.IncludeTotalResourceCount = true; -builder.Services.AddJsonApi(options => options.Namespace = "api/v1", resources: resourceGraphBuilder => resourceGraphBuilder.Add()); +#if DEBUG + options.IncludeExceptionStackTraceInErrors = true; + options.IncludeRequestBodyInErrors = true; + options.SerializerOptions.WriteIndented = true; +#endif +}, discovery => discovery.AddCurrentAssembly()); -builder.Services.AddResourceService(); +builder.Services.AddScoped(); WebApplication app = builder.Build(); @@ -22,20 +28,4 @@ app.UseJsonApi(); app.MapControllers(); -await CreateDatabaseAsync(app.Services); - app.Run(); - -static string? GetConnectionString(IConfiguration configuration) -{ - string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; - return configuration["Data:DefaultConnection"]?.Replace("###", postgresPassword); -} - -static async Task CreateDatabaseAsync(IServiceProvider serviceProvider) -{ - await using AsyncServiceScope scope = serviceProvider.CreateAsyncScope(); - - var dbContext = scope.ServiceProvider.GetRequiredService(); - await dbContext.Database.EnsureCreatedAsync(); -} diff --git a/src/Examples/NoEntityFrameworkExample/Properties/launchSettings.json b/src/Examples/NoEntityFrameworkExample/Properties/launchSettings.json index 82c88ace03..d1e2e0ca67 100644 --- a/src/Examples/NoEntityFrameworkExample/Properties/launchSettings.json +++ b/src/Examples/NoEntityFrameworkExample/Properties/launchSettings.json @@ -12,7 +12,7 @@ "IIS Express": { "commandName": "IISExpress", "launchBrowser": true, - "launchUrl": "api/v1/workItems", + "launchUrl": "api/todoItems?include=owner,assignee,tags&filter=equals(priority,'High')", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -20,7 +20,7 @@ "Kestrel": { "commandName": "Project", "launchBrowser": true, - "launchUrl": "api/v1/workItems", + "launchUrl": "api/todoItems?include=owner,assignee,tags&filter=equals(priority,'High')", "applicationUrl": "https://localhost:44349;http://localhost:14149", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/src/Examples/NoEntityFrameworkExample/QueryLayerIncludeConverter.cs b/src/Examples/NoEntityFrameworkExample/QueryLayerIncludeConverter.cs new file mode 100644 index 0000000000..c1db07b0fb --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/QueryLayerIncludeConverter.cs @@ -0,0 +1,81 @@ +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources.Annotations; + +namespace NoEntityFrameworkExample; + +/// +/// Replaces all s with s. +/// +internal sealed class QueryLayerIncludeConverter : QueryExpressionVisitor +{ + private readonly QueryLayer _queryLayer; + + public QueryLayerIncludeConverter(QueryLayer queryLayer) + { + _queryLayer = queryLayer; + } + + public void ConvertIncludesToSelections() + { + if (_queryLayer.Include != null) + { + Visit(_queryLayer.Include, _queryLayer); + _queryLayer.Include = null; + } + + EnsureNonEmptySelection(_queryLayer); + } + + public override object? VisitInclude(IncludeExpression expression, QueryLayer queryLayer) + { + foreach (IncludeElementExpression element in expression.Elements) + { + _ = Visit(element, queryLayer); + } + + return null; + } + + public override object? VisitIncludeElement(IncludeElementExpression expression, QueryLayer queryLayer) + { + QueryLayer subLayer = EnsureRelationshipInSelection(queryLayer, expression.Relationship); + + foreach (IncludeElementExpression nextIncludeElement in expression.Children) + { + Visit(nextIncludeElement, subLayer); + } + + return null; + } + + private static QueryLayer EnsureRelationshipInSelection(QueryLayer queryLayer, RelationshipAttribute relationship) + { + queryLayer.Selection ??= new FieldSelection(); + FieldSelectors selectors = queryLayer.Selection.GetOrCreateSelectors(queryLayer.ResourceType); + + if (!selectors.ContainsField(relationship)) + { + selectors.IncludeRelationship(relationship, new QueryLayer(relationship.RightType)); + } + + QueryLayer subLayer = selectors[relationship]!; + EnsureNonEmptySelection(subLayer); + + return subLayer; + } + + private static void EnsureNonEmptySelection(QueryLayer queryLayer) + { + if (queryLayer.Selection == null) + { + queryLayer.Selection = new FieldSelection(); + FieldSelectors selectors = queryLayer.Selection.GetOrCreateSelectors(queryLayer.ResourceType); + + foreach (AttrAttribute attribute in queryLayer.ResourceType.Attributes) + { + selectors.IncludeAttribute(attribute); + } + } + } +} diff --git a/src/Examples/NoEntityFrameworkExample/QueryLayerToLinqConverter.cs b/src/Examples/NoEntityFrameworkExample/QueryLayerToLinqConverter.cs new file mode 100644 index 0000000000..fb65a46015 --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/QueryLayerToLinqConverter.cs @@ -0,0 +1,44 @@ +using System.Collections; +using System.Linq.Expressions; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Internal.QueryableBuilding; +using JsonApiDotNetCore.Resources; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace NoEntityFrameworkExample; + +internal sealed class QueryLayerToLinqConverter +{ + private readonly IResourceFactory _resourceFactory; + private readonly IModel _model; + + public QueryLayerToLinqConverter(IResourceFactory resourceFactory, IModel model) + { + _resourceFactory = resourceFactory; + _model = model; + } + + public IEnumerable ApplyQueryLayer(QueryLayer queryLayer, IEnumerable resources) + where TResource : class, IIdentifiable + { + // The Include() extension method from Entity Framework Core is unavailable, so rewrite into selectors. + var converter = new QueryLayerIncludeConverter(queryLayer); + converter.ConvertIncludesToSelections(); + + // Convert QueryLayer into LINQ expression. + Expression source = ((IEnumerable)resources).AsQueryable().Expression; + var nameFactory = new LambdaParameterNameFactory(); + var queryableBuilder = new QueryableBuilder(source, queryLayer.ResourceType.ClrType, typeof(Enumerable), nameFactory, _resourceFactory, _model); + Expression expression = queryableBuilder.ApplyQuery(queryLayer); + + // Insert null checks to prevent a NullReferenceException during execution of expressions such as: + // 'todoItems => todoItems.Where(todoItem => todoItem.Assignee.Id == 1)' when a TodoItem doesn't have an assignee. + NullSafeExpressionRewriter rewriter = new(); + expression = rewriter.Rewrite(expression); + + // Compile and execute LINQ expression against the in-memory database. + Delegate function = Expression.Lambda(expression).Compile(); + object result = function.DynamicInvoke()!; + return (IEnumerable)result; + } +} diff --git a/src/Examples/NoEntityFrameworkExample/Repositories/InMemoryResourceRepository.cs b/src/Examples/NoEntityFrameworkExample/Repositories/InMemoryResourceRepository.cs new file mode 100644 index 0000000000..0b88ee3222 --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/Repositories/InMemoryResourceRepository.cs @@ -0,0 +1,60 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Internal.QueryableBuilding; +using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCore.Resources; +using NoEntityFrameworkExample.Data; + +namespace NoEntityFrameworkExample.Repositories; + +/// +/// Demonstrates how to replace the built-in . This read-only repository uses the built-in +/// to convert the incoming into a LINQ expression, then compiles and executes it against the +/// in-memory database. +/// +/// +/// The resource type. +/// +/// +/// The resource identifier type. +/// +public abstract class InMemoryResourceRepository : IResourceReadRepository + where TResource : class, IIdentifiable +{ + private readonly ResourceType _resourceType; + private readonly QueryLayerToLinqConverter _queryLayerToLinqConverter; + + protected InMemoryResourceRepository(IResourceGraph resourceGraph, IResourceFactory resourceFactory) + { + _resourceType = resourceGraph.GetResourceType(); + + var model = new InMemoryModel(resourceGraph); + _queryLayerToLinqConverter = new QueryLayerToLinqConverter(resourceFactory, model); + } + + /// + public Task> GetAsync(QueryLayer queryLayer, CancellationToken cancellationToken) + { + IEnumerable dataSource = GetDataSource(); + IEnumerable resources = _queryLayerToLinqConverter.ApplyQueryLayer(queryLayer, dataSource); + + return Task.FromResult>(resources.ToList()); + } + + /// + public Task CountAsync(FilterExpression? filter, CancellationToken cancellationToken) + { + var queryLayer = new QueryLayer(_resourceType) + { + Filter = filter + }; + + IEnumerable dataSource = GetDataSource(); + IEnumerable resources = _queryLayerToLinqConverter.ApplyQueryLayer(queryLayer, dataSource); + + return Task.FromResult(resources.Count()); + } + + protected abstract IEnumerable GetDataSource(); +} diff --git a/src/Examples/NoEntityFrameworkExample/Repositories/PersonRepository.cs b/src/Examples/NoEntityFrameworkExample/Repositories/PersonRepository.cs new file mode 100644 index 0000000000..d710cff0de --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/Repositories/PersonRepository.cs @@ -0,0 +1,21 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using NoEntityFrameworkExample.Data; +using NoEntityFrameworkExample.Models; + +namespace NoEntityFrameworkExample.Repositories; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class PersonRepository : InMemoryResourceRepository +{ + public PersonRepository(IResourceGraph resourceGraph, IResourceFactory resourceFactory) + : base(resourceGraph, resourceFactory) + { + } + + protected override IEnumerable GetDataSource() + { + return Database.People; + } +} diff --git a/src/Examples/NoEntityFrameworkExample/Repositories/TagRepository.cs b/src/Examples/NoEntityFrameworkExample/Repositories/TagRepository.cs new file mode 100644 index 0000000000..da38005bb3 --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/Repositories/TagRepository.cs @@ -0,0 +1,21 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using NoEntityFrameworkExample.Data; +using NoEntityFrameworkExample.Models; + +namespace NoEntityFrameworkExample.Repositories; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class TagRepository : InMemoryResourceRepository +{ + public TagRepository(IResourceGraph resourceGraph, IResourceFactory resourceFactory) + : base(resourceGraph, resourceFactory) + { + } + + protected override IEnumerable GetDataSource() + { + return Database.Tags; + } +} diff --git a/src/Examples/NoEntityFrameworkExample/Repositories/TodoItemRepository.cs b/src/Examples/NoEntityFrameworkExample/Repositories/TodoItemRepository.cs new file mode 100644 index 0000000000..38cd656e0a --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/Repositories/TodoItemRepository.cs @@ -0,0 +1,21 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using NoEntityFrameworkExample.Data; +using NoEntityFrameworkExample.Models; + +namespace NoEntityFrameworkExample.Repositories; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class TodoItemRepository : InMemoryResourceRepository +{ + public TodoItemRepository(IResourceGraph resourceGraph, IResourceFactory resourceFactory) + : base(resourceGraph, resourceFactory) + { + } + + protected override IEnumerable GetDataSource() + { + return Database.TodoItems; + } +} diff --git a/src/Examples/NoEntityFrameworkExample/Services/InMemoryResourceService.cs b/src/Examples/NoEntityFrameworkExample/Services/InMemoryResourceService.cs new file mode 100644 index 0000000000..de9450298f --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/Services/InMemoryResourceService.cs @@ -0,0 +1,209 @@ +using System.Collections; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Internal.QueryableBuilding; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Services; +using NoEntityFrameworkExample.Data; + +namespace NoEntityFrameworkExample.Services; + +/// +/// Demonstrates how to replace the built-in . This read-only resource service uses the built-in +/// to convert the incoming query string parameters into a , then uses the built-in +/// to convert the into a LINQ expression, then compiles and executes it against the in-memory +/// database. +/// +/// +/// +/// This resource service is a simplified version of the built-in resource service. Instead of implementing a resource service, consider implementing a +/// resource repository, which only needs to provide data access. +/// +/// The incoming filter from query string is logged, just to show how you can access it directly. +/// +/// +/// The resource type. +/// +/// +/// The resource identifier type. +/// +public abstract class InMemoryResourceService : IResourceQueryService + where TResource : class, IIdentifiable +{ + private readonly IJsonApiOptions _options; + private readonly IQueryLayerComposer _queryLayerComposer; + private readonly IPaginationContext _paginationContext; + private readonly IEnumerable _constraintProviders; + private readonly ILogger> _logger; + private readonly ResourceType _resourceType; + private readonly QueryLayerToLinqConverter _queryLayerToLinqConverter; + + protected InMemoryResourceService(IJsonApiOptions options, IResourceGraph resourceGraph, IQueryLayerComposer queryLayerComposer, + IResourceFactory resourceFactory, IPaginationContext paginationContext, IEnumerable constraintProviders, + ILoggerFactory loggerFactory) + { + _options = options; + _queryLayerComposer = queryLayerComposer; + _paginationContext = paginationContext; + _constraintProviders = constraintProviders; + + _logger = loggerFactory.CreateLogger>(); + _resourceType = resourceGraph.GetResourceType(); + + var model = new InMemoryModel(resourceGraph); + _queryLayerToLinqConverter = new QueryLayerToLinqConverter(resourceFactory, model); + } + + /// + public Task> GetAsync(CancellationToken cancellationToken) + { + LogFiltersInTopScope(); + + if (SetPrimaryTotalCountIsZero()) + { + return Task.FromResult>(Array.Empty()); + } + + QueryLayer queryLayer = _queryLayerComposer.ComposeFromConstraints(_resourceType); + + IEnumerable dataSource = GetDataSource(_resourceType).Cast(); + List resources = _queryLayerToLinqConverter.ApplyQueryLayer(queryLayer, dataSource).ToList(); + + if (queryLayer.Pagination?.PageSize?.Value == resources.Count) + { + _paginationContext.IsPageFull = true; + } + + return Task.FromResult>(resources); + } + + private void LogFiltersInTopScope() + { + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true + + FilterExpression[] filtersInTopScope = _constraintProviders.SelectMany(provider => provider.GetConstraints()) + .Where(constraint => constraint.Scope == null) + .Select(constraint => constraint.Expression) + .OfType() + .ToArray(); + + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore + + FilterExpression? filter = LogicalExpression.Compose(LogicalOperator.And, filtersInTopScope); + + if (filter != null) + { + _logger.LogInformation($"Incoming top-level filter from query string: {filter}"); + } + } + + private bool SetPrimaryTotalCountIsZero() + { + if (_options.IncludeTotalResourceCount) + { + var queryLayer = new QueryLayer(_resourceType) + { + Filter = _queryLayerComposer.GetPrimaryFilterFromConstraints(_resourceType) + }; + + IEnumerable dataSource = GetDataSource(_resourceType).Cast(); + IEnumerable resources = _queryLayerToLinqConverter.ApplyQueryLayer(queryLayer, dataSource); + + _paginationContext.TotalResourceCount = resources.Count(); + + if (_paginationContext.TotalResourceCount == 0) + { + return true; + } + } + + return false; + } + + /// + public Task GetAsync(TId id, CancellationToken cancellationToken) + { + QueryLayer queryLayer = _queryLayerComposer.ComposeForGetById(id, _resourceType, TopFieldSelection.PreserveExisting); + + IEnumerable dataSource = GetDataSource(_resourceType).Cast(); + IEnumerable resources = _queryLayerToLinqConverter.ApplyQueryLayer(queryLayer, dataSource); + TResource? resource = resources.SingleOrDefault(); + + if (resource == null) + { + throw new ResourceNotFoundException(id!.ToString()!, _resourceType.PublicName); + } + + return Task.FromResult(resource); + } + + /// + public Task GetSecondaryAsync(TId id, string relationshipName, CancellationToken cancellationToken) + { + RelationshipAttribute? relationship = _resourceType.FindRelationshipByPublicName(relationshipName); + + if (relationship == null) + { + throw new RelationshipNotFoundException(relationshipName, _resourceType.PublicName); + } + + SetNonPrimaryTotalCount(id, relationship); + + QueryLayer secondaryLayer = _queryLayerComposer.ComposeFromConstraints(relationship.RightType); + QueryLayer primaryLayer = _queryLayerComposer.WrapLayerForSecondaryEndpoint(secondaryLayer, _resourceType, id, relationship); + + IEnumerable dataSource = GetDataSource(_resourceType).Cast(); + IEnumerable primaryResources = _queryLayerToLinqConverter.ApplyQueryLayer(primaryLayer, dataSource); + TResource? primaryResource = primaryResources.SingleOrDefault(); + + if (primaryResource == null) + { + throw new ResourceNotFoundException(id!.ToString()!, _resourceType.PublicName); + } + + object? rightValue = relationship.GetValue(primaryResource); + + if (rightValue is ICollection rightResources && secondaryLayer.Pagination?.PageSize?.Value == rightResources.Count) + { + _paginationContext.IsPageFull = true; + } + + return Task.FromResult(rightValue); + } + + private void SetNonPrimaryTotalCount(TId id, RelationshipAttribute relationship) + { + if (_options.IncludeTotalResourceCount && relationship is HasManyAttribute hasManyRelationship) + { + FilterExpression? secondaryFilter = _queryLayerComposer.GetSecondaryFilterFromConstraints(id, hasManyRelationship); + + if (secondaryFilter == null) + { + return; + } + + var queryLayer = new QueryLayer(hasManyRelationship.RightType) + { + Filter = secondaryFilter + }; + + IEnumerable dataSource = GetDataSource(hasManyRelationship.RightType); + IEnumerable resources = _queryLayerToLinqConverter.ApplyQueryLayer(queryLayer, dataSource); + + _paginationContext.TotalResourceCount = resources.Count(); + } + } + + /// + public Task GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken) + { + return GetSecondaryAsync(id, relationshipName, cancellationToken); + } + + protected abstract IEnumerable GetDataSource(ResourceType resourceType); +} diff --git a/src/Examples/NoEntityFrameworkExample/Services/TodoItemService.cs b/src/Examples/NoEntityFrameworkExample/Services/TodoItemService.cs new file mode 100644 index 0000000000..11a4ad0b4a --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/Services/TodoItemService.cs @@ -0,0 +1,38 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Resources; +using NoEntityFrameworkExample.Data; +using NoEntityFrameworkExample.Models; + +namespace NoEntityFrameworkExample.Services; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class TodoItemService : InMemoryResourceService +{ + public TodoItemService(IJsonApiOptions options, IResourceGraph resourceGraph, IQueryLayerComposer queryLayerComposer, IResourceFactory resourceFactory, + IPaginationContext paginationContext, IEnumerable constraintProviders, ILoggerFactory loggerFactory) + : base(options, resourceGraph, queryLayerComposer, resourceFactory, paginationContext, constraintProviders, loggerFactory) + { + } + + protected override IEnumerable GetDataSource(ResourceType resourceType) + { + if (resourceType.ClrType == typeof(TodoItem)) + { + return Database.TodoItems; + } + + if (resourceType.ClrType == typeof(Person)) + { + return Database.People; + } + + if (resourceType.ClrType == typeof(Tag)) + { + return Database.Tags; + } + + throw new InvalidOperationException($"Unknown data source '{resourceType.ClrType}'."); + } +} diff --git a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs deleted file mode 100644 index 34a40755cb..0000000000 --- a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs +++ /dev/null @@ -1,109 +0,0 @@ -using System.Data; -using Dapper; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Services; -using NoEntityFrameworkExample.Models; -using Npgsql; - -namespace NoEntityFrameworkExample.Services; - -[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] -public sealed class WorkItemService : IResourceService -{ - private readonly string? _connectionString; - - public WorkItemService(IConfiguration configuration) - { - string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; - _connectionString = configuration["Data:DefaultConnection"]?.Replace("###", postgresPassword); - } - - public async Task> GetAsync(CancellationToken cancellationToken) - { - const string commandText = @"select * from ""WorkItems"""; - var commandDefinition = new CommandDefinition(commandText, cancellationToken: cancellationToken); - - return await QueryAsync(async connection => await connection.QueryAsync(commandDefinition)); - } - - public async Task GetAsync(int id, CancellationToken cancellationToken) - { - const string commandText = @"select * from ""WorkItems"" where ""Id""=@id"; - - var commandDefinition = new CommandDefinition(commandText, new - { - id - }, cancellationToken: cancellationToken); - - IReadOnlyCollection workItems = await QueryAsync(async connection => await connection.QueryAsync(commandDefinition)); - return workItems.Single(); - } - - public Task GetSecondaryAsync(int id, string relationshipName, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public Task GetRelationshipAsync(int id, string relationshipName, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public async Task CreateAsync(WorkItem resource, CancellationToken cancellationToken) - { - const string commandText = @"insert into ""WorkItems"" (""Title"", ""IsBlocked"", ""DurationInHours"", ""ProjectId"") values " + - @"(@title, @isBlocked, @durationInHours, @projectId) returning ""Id"", ""Title"", ""IsBlocked"", ""DurationInHours"", ""ProjectId"""; - - var commandDefinition = new CommandDefinition(commandText, new - { - title = resource.Title, - isBlocked = resource.IsBlocked, - durationInHours = resource.DurationInHours, - projectId = resource.ProjectId - }, cancellationToken: cancellationToken); - - IReadOnlyCollection workItems = await QueryAsync(async connection => await connection.QueryAsync(commandDefinition)); - return workItems.Single(); - } - - public Task AddToToManyRelationshipAsync(int leftId, string relationshipName, ISet rightResourceIds, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public Task UpdateAsync(int id, WorkItem resource, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public Task SetRelationshipAsync(int leftId, string relationshipName, object? rightValue, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public async Task DeleteAsync(int id, CancellationToken cancellationToken) - { - const string commandText = @"delete from ""WorkItems"" where ""Id""=@id"; - - await QueryAsync(async connection => await connection.QueryAsync(new CommandDefinition(commandText, new - { - id - }, cancellationToken: cancellationToken))); - } - - public Task RemoveFromToManyRelationshipAsync(int leftId, string relationshipName, ISet rightResourceIds, - CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - private async Task> QueryAsync(Func>> query) - { - using IDbConnection dbConnection = new NpgsqlConnection(_connectionString); - dbConnection.Open(); - - IEnumerable resources = await query(dbConnection); - return resources.ToList(); - } -} diff --git a/src/Examples/NoEntityFrameworkExample/appsettings.json b/src/Examples/NoEntityFrameworkExample/appsettings.json index cea6a7a623..603d1f4f9f 100644 --- a/src/Examples/NoEntityFrameworkExample/appsettings.json +++ b/src/Examples/NoEntityFrameworkExample/appsettings.json @@ -1,12 +1,11 @@ { - "Data": { - "DefaultConnection": "Host=localhost;Port=5432;Database=NoEntityFrameworkExample;User ID=postgres;Password=###" - }, "Logging": { "LogLevel": { "Default": "Warning", - "Microsoft.Hosting.Lifetime": "Warning", - "Microsoft.EntityFrameworkCore.Database.Command": "Critical" + // Include server startup, incoming requests and sample logging. + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.AspNetCore.Hosting.Diagnostics": "Information", + "NoEntityFrameworkExample": "Information" } }, "AllowedHosts": "*" diff --git a/src/Examples/ReportsExample/Program.cs b/src/Examples/ReportsExample/Program.cs index 16abac381f..04920d0068 100644 --- a/src/Examples/ReportsExample/Program.cs +++ b/src/Examples/ReportsExample/Program.cs @@ -4,7 +4,17 @@ // Add services to the container. -builder.Services.AddJsonApi(options => options.Namespace = "api", discovery => discovery.AddCurrentAssembly()); +builder.Services.AddJsonApi(options => +{ + options.Namespace = "api"; + options.UseRelativeLinks = true; + +#if DEBUG + options.IncludeExceptionStackTraceInErrors = true; + options.IncludeRequestBodyInErrors = true; + options.SerializerOptions.WriteIndented = true; +#endif +}, discovery => discovery.AddCurrentAssembly()); WebApplication app = builder.Build(); diff --git a/src/Examples/ReportsExample/ReportsExample.csproj b/src/Examples/ReportsExample/ReportsExample.csproj index b243e99ec2..a48f472a70 100644 --- a/src/Examples/ReportsExample/ReportsExample.csproj +++ b/src/Examples/ReportsExample/ReportsExample.csproj @@ -10,7 +10,7 @@ - + diff --git a/src/Examples/ReportsExample/appsettings.json b/src/Examples/ReportsExample/appsettings.json index 270cabc088..1e325ebe92 100644 --- a/src/Examples/ReportsExample/appsettings.json +++ b/src/Examples/ReportsExample/appsettings.json @@ -2,8 +2,9 @@ "Logging": { "LogLevel": { "Default": "Warning", + // Include server startup and incoming requests. "Microsoft.Hosting.Lifetime": "Information", - "Microsoft.EntityFrameworkCore.Database.Command": "Information" + "Microsoft.AspNetCore.Hosting.Diagnostics": "Information" } }, "AllowedHosts": "*" diff --git a/src/JsonApiDotNetCore.Annotations/CollectionConverter.cs b/src/JsonApiDotNetCore.Annotations/CollectionConverter.cs index fa1c0c90bd..6edce84335 100644 --- a/src/JsonApiDotNetCore.Annotations/CollectionConverter.cs +++ b/src/JsonApiDotNetCore.Annotations/CollectionConverter.cs @@ -45,7 +45,7 @@ public IEnumerable CopyToTypedCollection(IEnumerable source, Type collectionType /// private Type ToConcreteCollectionType(Type collectionType) { - if (collectionType.IsInterface && collectionType.IsGenericType) + if (collectionType is { IsInterface: true, IsGenericType: true }) { Type openCollectionType = collectionType.GetGenericTypeDefinition(); @@ -101,14 +101,11 @@ public IReadOnlyCollection ExtractResources(object? value) /// public Type? FindCollectionElementType(Type? type) { - if (type != null) + if (type is { IsGenericType: true, GenericTypeArguments.Length: 1 }) { - if (type.IsGenericType && type.GenericTypeArguments.Length == 1) + if (type.IsOrImplementsInterface()) { - if (type.IsOrImplementsInterface()) - { - return type.GenericTypeArguments[0]; - } + return type.GenericTypeArguments[0]; } } diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs index 7a6cbd960f..26a660775a 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs @@ -31,6 +31,7 @@ public AttrCapabilities Capabilities set => _capabilities = value; } + /// public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) @@ -48,6 +49,7 @@ public override bool Equals(object? obj) return Capabilities == other.Capabilities && base.Equals(other); } + /// public override int GetHashCode() { return HashCode.Combine(Capabilities, base.GetHashCode()); diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.cs index 5792744d5c..d310028ae6 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.cs @@ -1,3 +1,4 @@ +using System.Collections; using JetBrains.Annotations; // ReSharper disable NonReadonlyMemberInGetHashCode @@ -65,6 +66,34 @@ private bool EvaluateIsManyToMany() return false; } + /// + public override void SetValue(object resource, object? newValue) + { + ArgumentGuard.NotNull(newValue); + AssertIsIdentifiableCollection(newValue); + + base.SetValue(resource, newValue); + } + + private void AssertIsIdentifiableCollection(object newValue) + { + if (newValue is not IEnumerable enumerable) + { + throw new InvalidOperationException($"Resource of type '{newValue.GetType()}' must be a collection."); + } + + foreach (object? element in enumerable) + { + if (element == null) + { + throw new InvalidOperationException("Resource collection must not contain null values."); + } + + AssertIsIdentifiable(element); + } + } + + /// public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) @@ -82,6 +111,7 @@ public override bool Equals(object? obj) return _capabilities == other._capabilities && base.Equals(other); } + /// public override int GetHashCode() { return HashCode.Combine(_capabilities, base.GetHashCode()); diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasOneAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasOneAttribute.cs index c0416c92fb..72212c76f2 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasOneAttribute.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasOneAttribute.cs @@ -64,6 +64,14 @@ private bool EvaluateIsOneToOne() return false; } + /// + public override void SetValue(object resource, object? newValue) + { + AssertIsIdentifiable(newValue); + base.SetValue(resource, newValue); + } + + /// public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) @@ -81,6 +89,7 @@ public override bool Equals(object? obj) return _capabilities == other._capabilities && base.Equals(other); } + /// public override int GetHashCode() { return HashCode.Combine(_capabilities, base.GetHashCode()); diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/LinkTypes.shared.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/LinkTypes.shared.cs index 61c7e9d927..7e996828b9 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/LinkTypes.shared.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/LinkTypes.shared.cs @@ -5,8 +5,8 @@ public enum LinkTypes { Self = 1 << 0, Related = 1 << 1, - Paging = 1 << 2, + Pagination = 1 << 2, NotConfigured = 1 << 3, None = 1 << 4, - All = Self | Related | Paging + All = Self | Related | Pagination } diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.cs index dd94bab221..0b4848ada1 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.cs @@ -86,6 +86,7 @@ public bool CanInclude set => _canInclude = value; } + /// public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) @@ -103,6 +104,7 @@ public override bool Equals(object? obj) return _rightType?.ClrType == other._rightType?.ClrType && Links == other.Links && base.Equals(other); } + /// public override int GetHashCode() { return HashCode.Combine(_rightType?.ClrType, Links, base.GetHashCode()); diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceFieldAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceFieldAttribute.cs index e8e1d17aca..3a3707442c 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceFieldAttribute.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceFieldAttribute.cs @@ -68,6 +68,7 @@ internal set public object? GetValue(object resource) { ArgumentGuard.NotNull(resource); + AssertIsIdentifiable(resource); if (Property.GetMethod == null) { @@ -82,7 +83,7 @@ internal set { throw new InvalidOperationException( $"Unable to get property value of '{Property.DeclaringType!.Name}.{Property.Name}' on instance of type '{resource.GetType().Name}'.", - exception); + exception.InnerException ?? exception); } } @@ -90,9 +91,10 @@ internal set /// Sets the value of this field on the specified resource instance. Throws if the property is read-only or if the field does not belong to the specified /// resource instance. /// - public void SetValue(object resource, object? newValue) + public virtual void SetValue(object resource, object? newValue) { ArgumentGuard.NotNull(resource); + AssertIsIdentifiable(resource); if (Property.SetMethod == null) { @@ -107,15 +109,25 @@ public void SetValue(object resource, object? newValue) { throw new InvalidOperationException( $"Unable to set property value of '{Property.DeclaringType!.Name}.{Property.Name}' on instance of type '{resource.GetType().Name}'.", - exception); + exception.InnerException ?? exception); } } + protected void AssertIsIdentifiable(object? resource) + { + if (resource != null && resource is not IIdentifiable) + { + throw new InvalidOperationException($"Resource of type '{resource.GetType()}' does not implement {nameof(IIdentifiable)}."); + } + } + + /// public override string? ToString() { return _publicName ?? (_property != null ? _property.Name : base.ToString()); } + /// public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) @@ -133,6 +145,7 @@ public override bool Equals(object? obj) return _publicName == other._publicName && _property == other._property; } + /// public override int GetHashCode() { return HashCode.Combine(_publicName, _property); diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Internal/RuntimeTypeConverter.cs b/src/JsonApiDotNetCore.Annotations/Resources/Internal/RuntimeTypeConverter.cs index c76aa09b82..b209964232 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Internal/RuntimeTypeConverter.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Internal/RuntimeTypeConverter.cs @@ -46,7 +46,7 @@ public static class RuntimeTypeConverter return value; } - string? stringValue = value.ToString(); + string? stringValue = value is IFormattable cultureAwareValue ? cultureAwareValue.ToString(null, cultureInfo) : value.ToString(); if (string.IsNullOrEmpty(stringValue)) { diff --git a/src/JsonApiDotNetCore.OpenApi.Client/JsonApiClient.cs b/src/JsonApiDotNetCore.OpenApi.Client/JsonApiClient.cs index 219ac04353..deadccce07 100644 --- a/src/JsonApiDotNetCore.OpenApi.Client/JsonApiClient.cs +++ b/src/JsonApiDotNetCore.OpenApi.Client/JsonApiClient.cs @@ -122,9 +122,8 @@ public override void WriteJson(JsonWriter writer, object? value, JsonSerializer if (value != null) { - if (_alwaysIncludedAttributesPerRequestDocumentInstance.ContainsKey(value)) + if (_alwaysIncludedAttributesPerRequestDocumentInstance.TryGetValue(value, out AttributeNamesContainer? attributeNamesContainer)) { - AttributeNamesContainer attributeNamesContainer = _alwaysIncludedAttributesPerRequestDocumentInstance[value]; serializer.ContractResolver = new JsonApiDocumentContractResolver(attributeNamesContainer); } diff --git a/src/JsonApiDotNetCore.OpenApi/OpenApiEndpointConvention.cs b/src/JsonApiDotNetCore.OpenApi/OpenApiEndpointConvention.cs index c83266c1b0..1ec0d0dbcf 100644 --- a/src/JsonApiDotNetCore.OpenApi/OpenApiEndpointConvention.cs +++ b/src/JsonApiDotNetCore.OpenApi/OpenApiEndpointConvention.cs @@ -75,8 +75,8 @@ private IReadOnlyCollection GetRelationshipsOfPrimaryReso private static bool IsSecondaryOrRelationshipEndpoint(JsonApiEndpoint endpoint) { - return endpoint is JsonApiEndpoint.GetSecondary or JsonApiEndpoint.GetRelationship or JsonApiEndpoint.PostRelationship - or JsonApiEndpoint.PatchRelationship or JsonApiEndpoint.DeleteRelationship; + return endpoint is JsonApiEndpoint.GetSecondary or JsonApiEndpoint.GetRelationship or JsonApiEndpoint.PostRelationship or + JsonApiEndpoint.PatchRelationship or JsonApiEndpoint.DeleteRelationship; } private void SetResponseMetadata(ActionModel action, JsonApiEndpoint endpoint) @@ -160,7 +160,7 @@ private static void SetRequestMetadata(ActionModel action, JsonApiEndpoint endpo private static bool RequiresRequestBody(JsonApiEndpoint endpoint) { - return endpoint is JsonApiEndpoint.Post or JsonApiEndpoint.Patch or JsonApiEndpoint.PostRelationship or JsonApiEndpoint.PatchRelationship - or JsonApiEndpoint.DeleteRelationship; + return endpoint is JsonApiEndpoint.Post or JsonApiEndpoint.Patch or JsonApiEndpoint.PostRelationship or JsonApiEndpoint.PatchRelationship or + JsonApiEndpoint.DeleteRelationship; } } diff --git a/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ResourceObjectSchemaGenerator.cs b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ResourceObjectSchemaGenerator.cs index 090fcea2dd..c1b2654a2c 100644 --- a/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ResourceObjectSchemaGenerator.cs +++ b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ResourceObjectSchemaGenerator.cs @@ -132,9 +132,9 @@ private static void ReorderMembers(OpenApiSchema fullSchemaForResourceObject, IE foreach (string member in orderedMembers) { - if (fullSchemaForResourceObject.Properties.ContainsKey(member)) + if (fullSchemaForResourceObject.Properties.TryGetValue(member, out OpenApiSchema? schema)) { - reorderedMembers[member] = fullSchemaForResourceObject.Properties[member]; + reorderedMembers[member] = schema; } } diff --git a/src/JsonApiDotNetCore.SourceGenerators/SourceCodeWriter.cs b/src/JsonApiDotNetCore.SourceGenerators/SourceCodeWriter.cs index 13dc91a836..648906e901 100644 --- a/src/JsonApiDotNetCore.SourceGenerators/SourceCodeWriter.cs +++ b/src/JsonApiDotNetCore.SourceGenerators/SourceCodeWriter.cs @@ -61,12 +61,12 @@ public string Write(INamedTypeSymbol resourceType, ITypeSymbol idType, JsonApiEn WriteAutoGeneratedComment(); - if (idType.IsReferenceType && idType.NullableAnnotation == NullableAnnotation.Annotated) + if (idType is { IsReferenceType: true, NullableAnnotation: NullableAnnotation.Annotated }) { WriteNullableEnable(); } - WriteNamespaceImports(loggerFactoryInterface, resourceType); + WriteNamespaceImports(loggerFactoryInterface, resourceType, controllerNamespace); if (controllerNamespace != null) { @@ -96,7 +96,7 @@ private void WriteNullableEnable() _sourceBuilder.AppendLine(); } - private void WriteNamespaceImports(INamedTypeSymbol loggerFactoryInterface, INamedTypeSymbol resourceType) + private void WriteNamespaceImports(INamedTypeSymbol loggerFactoryInterface, INamedTypeSymbol resourceType, string? controllerNamespace) { _sourceBuilder.AppendLine($@"using {loggerFactoryInterface.ContainingNamespace};"); @@ -104,7 +104,7 @@ private void WriteNamespaceImports(INamedTypeSymbol loggerFactoryInterface, INam _sourceBuilder.AppendLine("using JsonApiDotNetCore.Controllers;"); _sourceBuilder.AppendLine("using JsonApiDotNetCore.Services;"); - if (!resourceType.ContainingNamespace.IsGlobalNamespace) + if (!resourceType.ContainingNamespace.IsGlobalNamespace && resourceType.ContainingNamespace.ToString() != controllerNamespace) { _sourceBuilder.AppendLine($"using {resourceType.ContainingNamespace};"); } @@ -130,21 +130,12 @@ private void WriteOpenClassDeclaration(string controllerName, JsonApiEndpointsCo private static string GetControllerBaseClassName(JsonApiEndpointsCopy endpointsToGenerate) { - switch (endpointsToGenerate) + return endpointsToGenerate switch { - case JsonApiEndpointsCopy.Query: - { - return "JsonApiQueryController"; - } - case JsonApiEndpointsCopy.Command: - { - return "JsonApiCommandController"; - } - default: - { - return "JsonApiController"; - } - } + JsonApiEndpointsCopy.Query => "JsonApiQueryController", + JsonApiEndpointsCopy.Command => "JsonApiCommandController", + _ => "JsonApiController" + }; } private void WriteConstructor(string controllerName, INamedTypeSymbol loggerFactoryInterface, JsonApiEndpointsCopy endpointsToGenerate, diff --git a/src/JsonApiDotNetCore/AtomicOperations/OperationProcessorAccessor.cs b/src/JsonApiDotNetCore/AtomicOperations/OperationProcessorAccessor.cs index c2397267b3..a052aa2d6e 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/OperationProcessorAccessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/OperationProcessorAccessor.cs @@ -40,36 +40,15 @@ protected virtual IOperationProcessor ResolveProcessor(OperationContainer operat private static Type GetProcessorInterface(WriteOperationKind writeOperation) { - switch (writeOperation) + return writeOperation switch { - case WriteOperationKind.CreateResource: - { - return typeof(ICreateProcessor<,>); - } - case WriteOperationKind.UpdateResource: - { - return typeof(IUpdateProcessor<,>); - } - case WriteOperationKind.DeleteResource: - { - return typeof(IDeleteProcessor<,>); - } - case WriteOperationKind.SetRelationship: - { - return typeof(ISetRelationshipProcessor<,>); - } - case WriteOperationKind.AddToRelationship: - { - return typeof(IAddToRelationshipProcessor<,>); - } - case WriteOperationKind.RemoveFromRelationship: - { - return typeof(IRemoveFromRelationshipProcessor<,>); - } - default: - { - throw new NotSupportedException($"Unknown write operation kind '{writeOperation}'."); - } - } + WriteOperationKind.CreateResource => typeof(ICreateProcessor<,>), + WriteOperationKind.UpdateResource => typeof(IUpdateProcessor<,>), + WriteOperationKind.DeleteResource => typeof(IDeleteProcessor<,>), + WriteOperationKind.SetRelationship => typeof(ISetRelationshipProcessor<,>), + WriteOperationKind.AddToRelationship => typeof(IAddToRelationshipProcessor<,>), + WriteOperationKind.RemoveFromRelationship => typeof(IRemoveFromRelationshipProcessor<,>), + _ => throw new NotSupportedException($"Unknown write operation kind '{writeOperation}'.") + }; } } diff --git a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs index bc7d17d89f..bd068b5496 100644 --- a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs @@ -15,7 +15,7 @@ public interface IJsonApiOptions /// /// /// /// string? Namespace { get; } @@ -64,8 +64,8 @@ public interface IJsonApiOptions /// "relationships": { /// "author": { /// "links": { - /// "self": "/api/v1/articles/4309/relationships/author", - /// "related": "/api/v1/articles/4309/author" + /// "self": "/api/shopping/articles/4309/relationships/author", + /// "related": "/api/shopping/articles/4309/author" /// } /// } /// } @@ -99,7 +99,7 @@ public interface IJsonApiOptions bool IncludeTotalResourceCount { get; } /// - /// The page size (10 by default) that is used when not specified in query string. Set to null to not use paging by default. + /// The page size (10 by default) that is used when not specified in query string. Set to null to not use pagination by default. /// PageSize? DefaultPageSize { get; } diff --git a/src/JsonApiDotNetCore/Configuration/ResourceDescriptorAssemblyCache.cs b/src/JsonApiDotNetCore/Configuration/ResourceDescriptorAssemblyCache.cs index e73b48ee3d..0e9f5d753f 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceDescriptorAssemblyCache.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceDescriptorAssemblyCache.cs @@ -12,10 +12,7 @@ internal sealed class ResourceDescriptorAssemblyCache public void RegisterAssembly(Assembly assembly) { - if (!_resourceDescriptorsPerAssembly.ContainsKey(assembly)) - { - _resourceDescriptorsPerAssembly[assembly] = null; - } + _resourceDescriptorsPerAssembly.TryAdd(assembly, null); } public IReadOnlyCollection GetResourceDescriptors() diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs index 2b6f19acd3..97548bd8fa 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs @@ -159,7 +159,7 @@ public ResourceGraphBuilder Add(DbContext dbContext) private static bool IsImplicitManyToManyJoinEntity(IEntityType entityType) { - return entityType.IsPropertyBag && entityType.HasSharedClrType; + return entityType is { IsPropertyBag: true, HasSharedClrType: true }; } /// diff --git a/src/JsonApiDotNetCore/Configuration/TypeLocator.cs b/src/JsonApiDotNetCore/Configuration/TypeLocator.cs index 981c50c4e4..9f65b46b97 100644 --- a/src/JsonApiDotNetCore/Configuration/TypeLocator.cs +++ b/src/JsonApiDotNetCore/Configuration/TypeLocator.cs @@ -89,7 +89,7 @@ internal sealed class TypeLocator private static (Type implementationType, Type serviceInterface)? GetContainerRegistrationFromType(Type nextType, Type unboundInterface, Type[] interfaceTypeArguments) { - if (!nextType.IsNested && !nextType.IsAbstract && !nextType.IsInterface) + if (nextType is { IsNested: false, IsAbstract: false, IsInterface: false }) { foreach (Type nextConstructedInterface in nextType.GetInterfaces().Where(type => type.IsGenericType)) { diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index 22efab2840..fb3cd2bd2d 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -207,7 +207,7 @@ public virtual async Task PostAsync([FromBody] TResource resource TResource? newResource = await _create.CreateAsync(resource, cancellationToken); string resourceId = (newResource ?? resource).StringId!; - string locationUrl = $"{HttpContext.Request.Path}/{resourceId}"; + string locationUrl = HttpContext.Request.Path.Add($"/{resourceId}"); if (newResource == null) { diff --git a/src/JsonApiDotNetCore/Diagnostics/CascadingCodeTimer.cs b/src/JsonApiDotNetCore/Diagnostics/CascadingCodeTimer.cs index a2aa7a379b..4b4d82b62b 100644 --- a/src/JsonApiDotNetCore/Diagnostics/CascadingCodeTimer.cs +++ b/src/JsonApiDotNetCore/Diagnostics/CascadingCodeTimer.cs @@ -238,12 +238,12 @@ private void WriteResult(StringBuilder builder, int indent, TimeSpan timeElapsed WriteIndent(builder, indent); builder.Append(Name); WritePadding(builder, indent, paddingLength); - builder.AppendFormat(CultureInfo.InvariantCulture, "{0,19:G}", timeElapsedInSelf); + builder.Append(CultureInfo.InvariantCulture, $"{timeElapsedInSelf,19:G}"); if (!_excludeInRelativeCost) { builder.Append(" ... "); - builder.AppendFormat(CultureInfo.InvariantCulture, "{0,7:#0.00%}", scaleElapsedInSelf); + builder.Append(CultureInfo.InvariantCulture, $"{scaleElapsedInSelf,7:#0.00%}"); } if (_stoppedAt == null) diff --git a/src/JsonApiDotNetCore/Errors/CannotClearRequiredRelationshipException.cs b/src/JsonApiDotNetCore/Errors/CannotClearRequiredRelationshipException.cs index b73b256d25..97377b0d7b 100644 --- a/src/JsonApiDotNetCore/Errors/CannotClearRequiredRelationshipException.cs +++ b/src/JsonApiDotNetCore/Errors/CannotClearRequiredRelationshipException.cs @@ -10,12 +10,11 @@ namespace JsonApiDotNetCore.Errors; [PublicAPI] public sealed class CannotClearRequiredRelationshipException : JsonApiException { - public CannotClearRequiredRelationshipException(string relationshipName, string resourceId, string resourceType) + public CannotClearRequiredRelationshipException(string relationshipName, string resourceType) : base(new ErrorObject(HttpStatusCode.BadRequest) { Title = "Failed to clear a required relationship.", - Detail = $"The relationship '{relationshipName}' on resource type '{resourceType}' " + - $"with ID '{resourceId}' cannot be cleared because it is a required relationship." + Detail = $"The relationship '{relationshipName}' on resource type '{resourceType}' cannot be cleared because it is a required relationship." }) { } diff --git a/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs b/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs index be4de87fc8..50cb511b14 100644 --- a/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs +++ b/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs @@ -67,7 +67,7 @@ private static IEnumerable FromModelStateDictionary(IReadOnlyDictio return ResolveSourcePointerInComplexType(propertySegment, resourceGraph); } - if (propertySegment.PropertyName == nameof(OperationContainer.Resource) && propertySegment.Parent != null && + if (propertySegment is { PropertyName: nameof(OperationContainer.Resource), Parent: not null } && propertySegment.Parent.ModelType == typeof(IList)) { // Special case: Stepping over OperationContainer.Resource property. diff --git a/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs b/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs index b8690402a5..80905d1231 100644 --- a/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs +++ b/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs @@ -71,15 +71,19 @@ protected virtual IReadOnlyList CreateErrorResponse(Exception excep { ArgumentGuard.NotNull(exception); - IReadOnlyList errors = exception is JsonApiException jsonApiException ? jsonApiException.Errors : - exception is OperationCanceledException ? new ErrorObject((HttpStatusCode)499) + IReadOnlyList errors = exception switch + { + JsonApiException jsonApiException => jsonApiException.Errors, + OperationCanceledException => new ErrorObject((HttpStatusCode)499) { Title = "Request execution was canceled." - }.AsArray() : new ErrorObject(HttpStatusCode.InternalServerError) + }.AsArray(), + _ => new ErrorObject(HttpStatusCode.InternalServerError) { Title = "An unhandled error occurred while processing this request.", Detail = exception.Message - }.AsArray(); + }.AsArray() + }; if (_options.IncludeExceptionStackTraceInErrors && exception is not InvalidModelStateException) { diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs b/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs index 5c6b84cba7..a6ef712adf 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs @@ -101,10 +101,10 @@ public void Apply(ApplicationModel application) $"resource type '{resourceClrType}', which does not exist in the resource graph."); } - if (_controllerPerResourceTypeMap.ContainsKey(resourceType)) + if (_controllerPerResourceTypeMap.TryGetValue(resourceType, out ControllerModel? existingModel)) { throw new InvalidConfigurationException( - $"Multiple controllers found for resource type '{resourceType}': '{_controllerPerResourceTypeMap[resourceType].ControllerType}' and '{controller.ControllerType}'."); + $"Multiple controllers found for resource type '{resourceType}': '{existingModel.ControllerType}' and '{controller.ControllerType}'."); } _resourceTypePerControllerTypeMap.Add(controller.ControllerType, resourceType); @@ -119,10 +119,10 @@ public void Apply(ApplicationModel application) string template = TemplateFromResource(controller) ?? TemplateFromController(controller); - if (_registeredControllerNameByTemplate.ContainsKey(template)) + if (_registeredControllerNameByTemplate.TryGetValue(template, out string? controllerName)) { throw new InvalidConfigurationException( - $"Cannot register '{controller.ControllerType.FullName}' for template '{template}' because '{_registeredControllerNameByTemplate[template]}' was already registered for this template."); + $"Cannot register '{controller.ControllerType.FullName}' for template '{template}' because '{controllerName}' was already registered for this template."); } _registeredControllerNameByTemplate.Add(template, controller.ControllerType.FullName!); diff --git a/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs index 578643d5db..e5ca0c0318 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs @@ -1,3 +1,4 @@ +using System.Globalization; using JetBrains.Annotations; namespace JsonApiDotNetCore.Queries.Expressions; @@ -8,13 +9,30 @@ namespace JsonApiDotNetCore.Queries.Expressions; [PublicAPI] public class LiteralConstantExpression : IdentifierExpression { - public string Value { get; } + // Only used to show the original input, in case expression parse failed. Not part of the semantic expression value. + private readonly string _stringValue; - public LiteralConstantExpression(string text) + public object TypedValue { get; } + + public LiteralConstantExpression(object typedValue) + : this(typedValue, GetStringValue(typedValue)!) + { + } + + public LiteralConstantExpression(object typedValue, string stringValue) + { + ArgumentGuard.NotNull(typedValue); + ArgumentGuard.NotNull(stringValue); + + TypedValue = typedValue; + _stringValue = stringValue; + } + + private static string? GetStringValue(object typedValue) { - ArgumentGuard.NotNull(text); + ArgumentGuard.NotNull(typedValue); - Value = text; + return typedValue is IFormattable cultureAwareValue ? cultureAwareValue.ToString(null, CultureInfo.InvariantCulture) : typedValue.ToString(); } public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) @@ -24,8 +42,8 @@ public override TResult Accept(QueryExpressionVisitor attributes) } } - public void IncludeRelationship(RelationshipAttribute relationship, QueryLayer? queryLayer) + public void IncludeRelationship(RelationshipAttribute relationship, QueryLayer queryLayer) { ArgumentGuard.NotNull(relationship); + ArgumentGuard.NotNull(queryLayer); this[relationship] = queryLayer; } diff --git a/src/JsonApiDotNetCore/Queries/IPaginationContext.cs b/src/JsonApiDotNetCore/Queries/IPaginationContext.cs index 44578e5277..e39b3ca354 100644 --- a/src/JsonApiDotNetCore/Queries/IPaginationContext.cs +++ b/src/JsonApiDotNetCore/Queries/IPaginationContext.cs @@ -3,18 +3,19 @@ namespace JsonApiDotNetCore.Queries; /// -/// Tracks values used for pagination, which is a combined effort from options, query string parsing and fetching the total number of rows. +/// Tracks values used for top-level pagination, which is a combined effort from options, query string parsing, resource definition callbacks and +/// fetching the total number of rows. /// public interface IPaginationContext { /// - /// The value 1, unless specified from query string. Never null. Cannot be higher than options.MaximumPageNumber. + /// The value 1, unless overridden from query string or resource definition. Should not be higher than . /// PageNumber PageNumber { get; set; } /// - /// The default page size from options, unless specified in query string. Can be null, which means no paging. Cannot be higher than - /// options.MaximumPageSize. + /// The default page size from options, unless overridden from query string or resource definition. Should not be higher than + /// . Can be null, which means pagination is disabled. /// PageSize? PageSize { get; set; } @@ -25,12 +26,12 @@ public interface IPaginationContext bool IsPageFull { get; set; } /// - /// The total number of resources. null when is set to false. + /// The total number of resources, or null when is set to false. /// int? TotalResourceCount { get; set; } /// - /// The total number of resource pages. null when is set to false or + /// The total number of resource pages, or null when is set to false or /// is null. /// int? TotalPageCount { get; } diff --git a/src/JsonApiDotNetCore/Queries/Internal/EvaluatedIncludeCache.cs b/src/JsonApiDotNetCore/Queries/Internal/EvaluatedIncludeCache.cs index bbd383fa28..6f3e0caf4e 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/EvaluatedIncludeCache.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/EvaluatedIncludeCache.cs @@ -5,7 +5,16 @@ namespace JsonApiDotNetCore.Queries.Internal; /// internal sealed class EvaluatedIncludeCache : IEvaluatedIncludeCache { + private readonly IEnumerable _constraintProviders; private IncludeExpression? _include; + private bool _isAssigned; + + public EvaluatedIncludeCache(IEnumerable constraintProviders) + { + ArgumentGuard.NotNull(constraintProviders); + + _constraintProviders = constraintProviders; + } /// public void Set(IncludeExpression include) @@ -13,11 +22,31 @@ public void Set(IncludeExpression include) ArgumentGuard.NotNull(include); _include = include; + _isAssigned = true; } /// public IncludeExpression? Get() { + if (!_isAssigned) + { + // In case someone has replaced the built-in JsonApiResourceService with their own that "forgets" to populate the cache, + // then as a fallback, we feed the requested includes from query string to the response serializer. + + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true + + _include = _constraintProviders.SelectMany(provider => provider.GetConstraints()) + .Where(constraint => constraint.Scope == null) + .Select(constraint => constraint.Expression) + .OfType() + .FirstOrDefault(); + + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore + _isAssigned = true; + } + return _include; } } diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs index b768eb15b1..541b50a220 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs @@ -1,11 +1,11 @@ using System.Collections.Immutable; -using System.Reflection; using Humanizer; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Resources.Internal; namespace JsonApiDotNetCore.Queries.Internal.Parsing; @@ -141,28 +141,32 @@ protected ComparisonExpression ParseComparison(string operatorName) : FieldChainRequirements.EndsInAttribute; QueryExpression leftTerm = ParseCountOrField(leftChainRequirements); + Converter rightConstantValueConverter; + + if (leftTerm is CountExpression) + { + rightConstantValueConverter = GetConstantValueConverterForCount(); + } + else if (leftTerm is ResourceFieldChainExpression fieldChain && fieldChain.Fields[^1] is AttrAttribute attribute) + { + rightConstantValueConverter = GetConstantValueConverterForAttribute(attribute); + } + else + { + // This temporary value never survives; it gets discarded during the second pass below. + rightConstantValueConverter = _ => 0; + } EatSingleCharacterToken(TokenKind.Comma); - QueryExpression rightTerm = ParseCountOrConstantOrNullOrField(FieldChainRequirements.EndsInAttribute); + QueryExpression rightTerm = ParseCountOrConstantOrNullOrField(FieldChainRequirements.EndsInAttribute, rightConstantValueConverter); EatSingleCharacterToken(TokenKind.CloseParen); - if (leftTerm is ResourceFieldChainExpression leftChain) + if (leftTerm is ResourceFieldChainExpression leftChain && leftChain.Fields[^1] is RelationshipAttribute && rightTerm is not NullConstantExpression) { - if (leftChainRequirements.HasFlag(FieldChainRequirements.EndsInToOne) && rightTerm is not NullConstantExpression) - { - // Run another pass over left chain to have it fail when chain ends in relationship. - OnResolveFieldChain(leftChain.ToString(), FieldChainRequirements.EndsInAttribute); - } - - PropertyInfo leftProperty = leftChain.Fields[^1].Property; - - if (leftProperty.Name == nameof(Identifiable.Id) && rightTerm is LiteralConstantExpression rightConstant) - { - string id = DeObfuscateStringId(leftProperty.ReflectedType!, rightConstant.Value); - rightTerm = new LiteralConstantExpression(id); - } + // Run another pass over left chain to produce an error. + OnResolveFieldChain(leftChain.ToString(), FieldChainRequirements.EndsInAttribute); } return new ComparisonExpression(comparisonOperator, leftTerm, rightTerm); @@ -173,16 +177,23 @@ protected MatchTextExpression ParseTextMatch(string matchFunctionName) EatText(matchFunctionName); EatSingleCharacterToken(TokenKind.OpenParen); - ResourceFieldChainExpression targetAttribute = ParseFieldChain(FieldChainRequirements.EndsInAttribute, null); + ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(FieldChainRequirements.EndsInAttribute, null); + Type targetAttributeType = ((AttrAttribute)targetAttributeChain.Fields[^1]).Property.PropertyType; + + if (targetAttributeType != typeof(string)) + { + throw new QueryParseException("Attribute of type 'String' expected."); + } EatSingleCharacterToken(TokenKind.Comma); - LiteralConstantExpression constant = ParseConstant(); + Converter constantValueConverter = stringValue => stringValue; + LiteralConstantExpression constant = ParseConstant(constantValueConverter); EatSingleCharacterToken(TokenKind.CloseParen); var matchKind = Enum.Parse(matchFunctionName.Pascalize()); - return new MatchTextExpression(targetAttribute, constant, matchKind); + return new MatchTextExpression(targetAttributeChain, constant, matchKind); } protected AnyExpression ParseAny() @@ -191,19 +202,20 @@ protected AnyExpression ParseAny() EatSingleCharacterToken(TokenKind.OpenParen); ResourceFieldChainExpression targetAttribute = ParseFieldChain(FieldChainRequirements.EndsInAttribute, null); + Converter constantValueConverter = GetConstantValueConverterForAttribute((AttrAttribute)targetAttribute.Fields[^1]); EatSingleCharacterToken(TokenKind.Comma); ImmutableHashSet.Builder constantsBuilder = ImmutableHashSet.CreateBuilder(); - LiteralConstantExpression constant = ParseConstant(); + LiteralConstantExpression constant = ParseConstant(constantValueConverter); constantsBuilder.Add(constant); while (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) { EatSingleCharacterToken(TokenKind.Comma); - constant = ParseConstant(); + constant = ParseConstant(constantValueConverter); constantsBuilder.Add(constant); } @@ -211,32 +223,9 @@ protected AnyExpression ParseAny() IImmutableSet constantSet = constantsBuilder.ToImmutable(); - PropertyInfo targetAttributeProperty = targetAttribute.Fields[^1].Property; - - if (targetAttributeProperty.Name == nameof(Identifiable.Id)) - { - constantSet = DeObfuscateIdConstants(constantSet, targetAttributeProperty); - } - return new AnyExpression(targetAttribute, constantSet); } - private IImmutableSet DeObfuscateIdConstants(IImmutableSet constantSet, - PropertyInfo targetAttributeProperty) - { - ImmutableHashSet.Builder idConstantsBuilder = ImmutableHashSet.CreateBuilder(); - - foreach (LiteralConstantExpression idConstant in constantSet) - { - string stringId = idConstant.Value; - string id = DeObfuscateStringId(targetAttributeProperty.ReflectedType!, stringId); - - idConstantsBuilder.Add(new LiteralConstantExpression(id)); - } - - return idConstantsBuilder.ToImmutable(); - } - protected HasExpression ParseHas() { EatText(Keywords.Has); @@ -360,7 +349,7 @@ protected QueryExpression ParseCountOrField(FieldChainRequirements chainRequirem return ParseFieldChain(chainRequirements, "Count function or field name expected."); } - protected QueryExpression ParseCountOrConstantOrNullOrField(FieldChainRequirements chainRequirements) + protected QueryExpression ParseCountOrConstantOrNullOrField(FieldChainRequirements chainRequirements, Converter constantValueConverter) { CountExpression? count = TryParseCount(); @@ -369,7 +358,7 @@ protected QueryExpression ParseCountOrConstantOrNullOrField(FieldChainRequiremen return count; } - IdentifierExpression? constantOrNull = TryParseConstantOrNull(); + IdentifierExpression? constantOrNull = TryParseConstantOrNull(constantValueConverter); if (constantOrNull != null) { @@ -379,11 +368,11 @@ protected QueryExpression ParseCountOrConstantOrNullOrField(FieldChainRequiremen return ParseFieldChain(chainRequirements, "Count function, value between quotes, null or field name expected."); } - protected IdentifierExpression? TryParseConstantOrNull() + protected IdentifierExpression? TryParseConstantOrNull(Converter constantValueConverter) { if (TokenStack.TryPeek(out Token? nextToken)) { - if (nextToken.Kind == TokenKind.Text && nextToken.Value == Keywords.Null) + if (nextToken is { Kind: TokenKind.Text, Value: Keywords.Null }) { TokenStack.Pop(); return NullConstantExpression.Instance; @@ -392,28 +381,55 @@ protected QueryExpression ParseCountOrConstantOrNullOrField(FieldChainRequiremen if (nextToken.Kind == TokenKind.QuotedText) { TokenStack.Pop(); - return new LiteralConstantExpression(nextToken.Value!); + + object constantValue = constantValueConverter(nextToken.Value!); + return new LiteralConstantExpression(constantValue, nextToken.Value!); } } return null; } - protected LiteralConstantExpression ParseConstant() + protected LiteralConstantExpression ParseConstant(Converter constantValueConverter) { if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.QuotedText) { - return new LiteralConstantExpression(token.Value!); + object constantValue = constantValueConverter(token.Value!); + return new LiteralConstantExpression(constantValue, token.Value!); } throw new QueryParseException("Value between quotes expected."); } - private string DeObfuscateStringId(Type resourceClrType, string stringId) + private Converter GetConstantValueConverterForCount() + { + return stringValue => ConvertStringToType(stringValue, typeof(int)); + } + + private object ConvertStringToType(string value, Type type) + { + try + { + return RuntimeTypeConverter.ConvertType(value, type)!; + } + catch (FormatException) + { + throw new QueryParseException($"Failed to convert '{value}' of type 'String' to type '{type.Name}'."); + } + } + + private Converter GetConstantValueConverterForAttribute(AttrAttribute attribute) + { + return stringValue => attribute.Property.Name == nameof(Identifiable.Id) + ? DeObfuscateStringId(attribute.Type.ClrType, stringValue) + : ConvertStringToType(stringValue, attribute.Property.PropertyType); + } + + private object DeObfuscateStringId(Type resourceClrType, string stringId) { IIdentifiable tempResource = _resourceFactory.CreateInstance(resourceClrType); tempResource.StringId = stringId; - return tempResource.GetTypedId().ToString()!; + return tempResource.GetTypedId(); } protected override IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryExpressionParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryExpressionParser.cs index 681c1dd8f4..27466e3b0a 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryExpressionParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryExpressionParser.cs @@ -72,7 +72,7 @@ private void EatFieldChain(StringBuilder pathBuilder, string? alternativeErrorMe protected CountExpression? TryParseCount() { - if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Text && nextToken.Value == Keywords.Count) + if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text, Value: Keywords.Count }) { TokenStack.Pop(); diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs index 4661a5bdda..e22b4ba86b 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs @@ -82,13 +82,11 @@ public QueryLayerComposer(IEnumerable constraintProvid ExpressionInScope[] constraints = _constraintProviders.SelectMany(provider => provider.GetConstraints()).ToArray(); - var secondaryScope = new ResourceFieldChainExpression(hasManyRelationship); - // @formatter:wrap_chained_method_calls chop_always // @formatter:keep_existing_linebreaks true FilterExpression[] filtersInSecondaryScope = constraints - .Where(constraint => secondaryScope.Equals(constraint.Scope)) + .Where(constraint => constraint.Scope == null) .Select(constraint => constraint.Expression) .OfType() .ToArray(); @@ -116,14 +114,14 @@ private static FilterExpression GetInverseHasOneRelationshipFilter(TId prim AttrAttribute idAttribute = GetIdAttribute(relationship.LeftType); var idChain = new ResourceFieldChainExpression(ImmutableArray.Create(inverseRelationship, idAttribute)); - return new ComparisonExpression(ComparisonOperator.Equals, idChain, new LiteralConstantExpression(primaryId!.ToString()!)); + return new ComparisonExpression(ComparisonOperator.Equals, idChain, new LiteralConstantExpression(primaryId!)); } private static FilterExpression GetInverseHasManyRelationshipFilter(TId primaryId, HasManyAttribute relationship, HasManyAttribute inverseRelationship) { AttrAttribute idAttribute = GetIdAttribute(relationship.LeftType); var idChain = new ResourceFieldChainExpression(ImmutableArray.Create(idAttribute)); - var idComparison = new ComparisonExpression(ComparisonOperator.Equals, idChain, new LiteralConstantExpression(primaryId!.ToString()!)); + var idComparison = new ComparisonExpression(ComparisonOperator.Equals, idChain, new LiteralConstantExpression(primaryId!)); return new HasExpression(new ResourceFieldChainExpression(inverseRelationship), idComparison); } @@ -362,12 +360,12 @@ private IncludeExpression RewriteIncludeForSecondaryEndpoint(IncludeExpression? if (ids.Count == 1) { - var constant = new LiteralConstantExpression(ids.Single()!.ToString()!); + var constant = new LiteralConstantExpression(ids.Single()!); filter = new ComparisonExpression(ComparisonOperator.Equals, idChain, constant); } else if (ids.Count > 1) { - ImmutableHashSet constants = ids.Select(id => new LiteralConstantExpression(id!.ToString()!)).ToImmutableHashSet(); + ImmutableHashSet constants = ids.Select(id => new LiteralConstantExpression(id!)).ToImmutableHashSet(); filter = new AnyExpression(idChain, constants); } diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs index 1f1c10301a..d206bd8b17 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs @@ -136,10 +136,10 @@ private ICollection ToPropertySelectors(FieldSelectors fieldSe if (fieldSelectors.ContainsReadOnlyAttribute || fieldSelectors.ContainsOnlyRelationships) { - // If a read-only attribute is selected, its calculated value likely depends on another property, so select all properties. - // And only selecting relationships implicitly means to select all attributes too. + // If a read-only attribute is selected, its calculated value likely depends on another property, so fetch all scalar properties. + // And only selecting relationships implicitly means to fetch all scalar properties as well. - IncludeAllAttributes(elementType, propertySelectors); + IncludeAllScalarProperties(elementType, propertySelectors); } IncludeFields(fieldSelectors, propertySelectors); @@ -148,23 +148,28 @@ private ICollection ToPropertySelectors(FieldSelectors fieldSe return propertySelectors.Values; } - private void IncludeAllAttributes(Type elementType, Dictionary propertySelectors) + private void IncludeAllScalarProperties(Type elementType, Dictionary propertySelectors) { - IEntityType entityModel = _entityModel.GetEntityTypes().Single(type => type.ClrType == elementType); - IEnumerable entityProperties = entityModel.GetProperties().Where(property => !property.IsShadowProperty()).ToArray(); + IEntityType entityType = _entityModel.GetEntityTypes().Single(type => type.ClrType == elementType); - foreach (IProperty entityProperty in entityProperties) + foreach (IProperty property in entityType.GetProperties().Where(property => !property.IsShadowProperty())) { - var propertySelector = new PropertySelector(entityProperty.PropertyInfo!); + var propertySelector = new PropertySelector(property.PropertyInfo!); + IncludeWritableProperty(propertySelector, propertySelectors); + } + + foreach (INavigation navigation in entityType.GetNavigations().Where(navigation => navigation.ForeignKey.IsOwnership && !navigation.IsShadowProperty())) + { + var propertySelector = new PropertySelector(navigation.PropertyInfo!); IncludeWritableProperty(propertySelector, propertySelectors); } } private static void IncludeFields(FieldSelectors fieldSelectors, Dictionary propertySelectors) { - foreach ((ResourceFieldAttribute resourceField, QueryLayer? queryLayer) in fieldSelectors) + foreach ((ResourceFieldAttribute resourceField, QueryLayer? nextLayer) in fieldSelectors) { - var propertySelector = new PropertySelector(resourceField.Property, queryLayer); + var propertySelector = new PropertySelector(resourceField.Property, nextLayer); IncludeWritableProperty(propertySelector, propertySelectors); } } @@ -185,10 +190,7 @@ private static void IncludeEagerLoads(ResourceType resourceType, Dictionary calls. /// [PublicAPI] -public class WhereClauseBuilder : QueryClauseBuilder +public class WhereClauseBuilder : QueryClauseBuilder { private static readonly CollectionConverter CollectionConverter = new(); private static readonly ConstantExpression NullConstant = Expression.Constant(null); @@ -53,7 +53,7 @@ private Expression WhereExtensionMethodCall(LambdaExpression predicate) return Expression.Call(_extensionType, "Where", LambdaScope.Parameter.Type.AsArray(), _source, predicate); } - public override Expression VisitHas(HasExpression expression, Type? argument) + public override Expression VisitHas(HasExpression expression, object? argument) { Expression property = Visit(expression.TargetCollection, argument); @@ -85,7 +85,7 @@ private static MethodCallExpression AnyExtensionMethodCall(Type elementType, Exp : Expression.Call(typeof(Enumerable), "Any", elementType.AsArray(), source); } - public override Expression VisitIsType(IsTypeExpression expression, Type? argument) + public override Expression VisitIsType(IsTypeExpression expression, object? argument) { Expression property = expression.TargetToOneRelationship != null ? Visit(expression.TargetToOneRelationship, argument) : LambdaScope.Accessor; TypeBinaryExpression typeCheck = Expression.TypeIs(property, expression.DerivedType.ClrType); @@ -101,7 +101,7 @@ public override Expression VisitIsType(IsTypeExpression expression, Type? argume return Expression.AndAlso(typeCheck, filter); } - public override Expression VisitMatchText(MatchTextExpression expression, Type? argument) + public override Expression VisitMatchText(MatchTextExpression expression, object? argument) { Expression property = Visit(expression.TargetAttribute, argument); @@ -125,7 +125,7 @@ public override Expression VisitMatchText(MatchTextExpression expression, Type? return Expression.Call(property, "Contains", null, text); } - public override Expression VisitAny(AnyExpression expression, Type? argument) + public override Expression VisitAny(AnyExpression expression, object? argument) { Expression property = Visit(expression.TargetAttribute, argument); @@ -133,8 +133,7 @@ public override Expression VisitAny(AnyExpression expression, Type? argument) foreach (LiteralConstantExpression constant in expression.Constants) { - object? value = ConvertTextToTargetType(constant.Value, property.Type); - valueList.Add(value); + valueList.Add(constant.TypedValue); } ConstantExpression collection = Expression.Constant(valueList); @@ -146,7 +145,7 @@ private static Expression ContainsExtensionMethodCall(Expression collection, Exp return Expression.Call(typeof(Enumerable), "Contains", value.Type.AsArray(), collection, value); } - public override Expression VisitLogical(LogicalExpression expression, Type? argument) + public override Expression VisitLogical(LogicalExpression expression, object? argument) { var termQueue = new Queue(expression.Terms.Select(filter => Visit(filter, argument))); @@ -179,44 +178,28 @@ private static BinaryExpression Compose(Queue argumentQueue, Func Expression.Equal(left, right), + ComparisonOperator.LessThan => Expression.LessThan(left, right), + ComparisonOperator.LessOrEqual => Expression.LessThanOrEqual(left, right), + ComparisonOperator.GreaterThan => Expression.GreaterThan(left, right), + ComparisonOperator.GreaterOrEqual => Expression.GreaterThanOrEqual(left, right), + _ => throw new InvalidOperationException($"Unknown comparison operator '{expression.Operator}'.") + }; } private Type ResolveCommonType(QueryExpression left, QueryExpression right) @@ -277,27 +260,14 @@ private static Expression WrapInConvert(Expression expression, Type? targetType) } } - public override Expression VisitNullConstant(NullConstantExpression expression, Type? expressionType) + public override Expression VisitNullConstant(NullConstantExpression expression, object? argument) { return NullConstant; } - public override Expression VisitLiteralConstant(LiteralConstantExpression expression, Type? expressionType) - { - object? convertedValue = expressionType != null ? ConvertTextToTargetType(expression.Value, expressionType) : expression.Value; - - return convertedValue.CreateTupleAccessExpressionForConstant(expressionType ?? typeof(string)); - } - - private static object? ConvertTextToTargetType(string text, Type targetType) + public override Expression VisitLiteralConstant(LiteralConstantExpression expression, object? argument) { - try - { - return RuntimeTypeConverter.ConvertType(text, targetType); - } - catch (FormatException exception) - { - throw new InvalidQueryException("Query creation failed due to incompatible types.", exception); - } + Type type = expression.TypedValue.GetType(); + return expression.TypedValue.CreateTupleAccessExpressionForConstant(type); } } diff --git a/src/JsonApiDotNetCore/Queries/Internal/SystemExpressionExtensions.cs b/src/JsonApiDotNetCore/Queries/Internal/SystemExpressionExtensions.cs index a314d5f20a..9c77f53938 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/SystemExpressionExtensions.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/SystemExpressionExtensions.cs @@ -21,7 +21,7 @@ public static Expression CreateTupleAccessExpressionForConstant(this object? val // Tuple.Create(value).Item1; MethodInfo tupleCreateUnboundMethod = typeof(Tuple).GetMethods() - .Single(method => method.Name == "Create" && method.IsGenericMethod && method.GetGenericArguments().Length == 1); + .Single(method => method is { Name: "Create", IsGenericMethod: true } && method.GetGenericArguments().Length == 1); MethodInfo tupleCreateClosedMethod = tupleCreateUnboundMethod.MakeGenericMethod(type); diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/PaginationQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/PaginationQueryStringParameterReader.cs index 3e4293c5e0..c6ec12afb6 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/PaginationQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/PaginationQueryStringParameterReader.cs @@ -73,7 +73,7 @@ public virtual void Read(string parameterName, StringValues parameterValue) } catch (QueryParseException exception) { - throw new InvalidQueryStringParameterException(parameterName, "The specified paging is invalid.", exception.Message, exception); + throw new InvalidQueryStringParameterException(parameterName, "The specified pagination is invalid.", exception.Message, exception); } } @@ -154,11 +154,7 @@ public MutablePaginationEntry ResolveEntryInScope(ResourceFieldChainExpression? return _globalScope; } - if (!_nestedScopes.ContainsKey(scope)) - { - _nestedScopes.Add(scope, new MutablePaginationEntry()); - } - + _nestedScopes.TryAdd(scope, new MutablePaginationEntry()); return _nestedScopes[scope]; } diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 7cdd114301..66abfafbe0 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -151,7 +151,24 @@ protected virtual IQueryable ApplyQueryLayer(QueryLayer queryLayer) protected virtual IQueryable GetAll() { - return _dbContext.Set(); + IQueryable source = _dbContext.Set(); + + return GetTrackingBehavior() switch + { + QueryTrackingBehavior.NoTrackingWithIdentityResolution => source.AsNoTrackingWithIdentityResolution(), + QueryTrackingBehavior.NoTracking => source.AsNoTracking(), + QueryTrackingBehavior.TrackAll => source.AsTracking(), + _ => source + }; + } + + protected virtual QueryTrackingBehavior? GetTrackingBehavior() + { + // EF Core rejects the way we project sparse fieldsets when owned entities are involved, unless the query is explicitly + // marked as non-tracked (see https://github.com/dotnet/EntityFramework.Docs/issues/2205#issuecomment-1542914439). +#pragma warning disable CS0618 + return _resourceDefinitionAccessor.IsReadOnlyRequest ? QueryTrackingBehavior.NoTrackingWithIdentityResolution : null; +#pragma warning restore CS0618 } /// @@ -163,6 +180,8 @@ public virtual Task GetForCreateAsync(Type resourceClrType, TId id, C id }); + ArgumentGuard.NotNull(resourceClrType); + var resource = (TResource)_resourceFactory.CreateInstance(resourceClrType); resource.Id = id; @@ -269,7 +288,7 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r object? rightValueEvaluated = await VisitSetRelationshipAsync(resourceFromDatabase, relationship, rightValue, WriteOperationKind.UpdateResource, cancellationToken); - AssertIsNotClearingRequiredToOneRelationship(relationship, resourceFromDatabase, rightValueEvaluated); + AssertIsNotClearingRequiredToOneRelationship(relationship, rightValueEvaluated); await UpdateRelationshipAsync(relationship, resourceFromDatabase, rightValueEvaluated, cancellationToken); } @@ -288,7 +307,7 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r _dbContext.ResetChangeTracker(); } - protected void AssertIsNotClearingRequiredToOneRelationship(RelationshipAttribute relationship, TResource leftResource, object? rightValue) + protected void AssertIsNotClearingRequiredToOneRelationship(RelationshipAttribute relationship, object? rightValue) { if (relationship is HasOneAttribute) { @@ -300,7 +319,7 @@ protected void AssertIsNotClearingRequiredToOneRelationship(RelationshipAttribut if (isRelationshipRequired && isClearingRelationship) { string resourceName = _resourceGraph.GetResourceType().PublicName; - throw new CannotClearRequiredRelationshipException(relationship.PublicName, leftResource.StringId!, resourceName); + throw new CannotClearRequiredRelationshipException(relationship.PublicName, resourceName); } } } @@ -347,21 +366,12 @@ private NavigationEntry GetNavigationEntry(TResource resource, RelationshipAttri { EntityEntry entityEntry = _dbContext.Entry(resource); - switch (relationship) + return relationship switch { - case HasOneAttribute hasOneRelationship: - { - return entityEntry.Reference(hasOneRelationship.Property.Name); - } - case HasManyAttribute hasManyRelationship: - { - return entityEntry.Collection(hasManyRelationship.Property.Name); - } - default: - { - throw new InvalidOperationException($"Unknown relationship type '{relationship.GetType().Name}'."); - } - } + HasOneAttribute hasOneRelationship => entityEntry.Reference(hasOneRelationship.Property.Name), + HasManyAttribute hasManyRelationship => entityEntry.Collection(hasManyRelationship.Property.Name), + _ => throw new InvalidOperationException($"Unknown relationship type '{relationship.GetType().Name}'.") + }; } private bool RequiresLoadOfRelationshipForDeletion(RelationshipAttribute relationship) @@ -403,7 +413,7 @@ public virtual async Task SetRelationshipAsync(TResource leftResource, object? r object? rightValueEvaluated = await VisitSetRelationshipAsync(leftResource, relationship, rightValue, WriteOperationKind.SetRelationship, cancellationToken); - AssertIsNotClearingRequiredToOneRelationship(relationship, leftResource, rightValueEvaluated); + AssertIsNotClearingRequiredToOneRelationship(relationship, rightValueEvaluated); await UpdateRelationshipAsync(relationship, leftResource, rightValueEvaluated, cancellationToken); @@ -529,8 +539,6 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TResource leftResour if (!rightResourceIdsToStore.SetEquals(rightResourceIdsStored)) { - AssertIsNotClearingRequiredToOneRelationship(relationship, leftResourceTracked, rightResourceIdsToStore); - await UpdateRelationshipAsync(relationship, leftResourceTracked, rightResourceIdsToStore, cancellationToken); await _resourceDefinitionAccessor.OnWritingAsync(leftResourceTracked, WriteOperationKind.RemoveFromRelationship, cancellationToken); diff --git a/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs b/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs index 9af79831b2..41d366b5c3 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs @@ -60,7 +60,7 @@ public interface IResourceDefinition /// An optional existing pagination, coming from query string. Can be null. /// /// - /// The changed pagination, or null to use the first page with default size from options. To disable paging, set + /// The changed pagination, or null to use the first page with default size from options. To disable pagination, set /// to null. /// PaginationExpression? OnApplyPagination(PaginationExpression? existingPagination); @@ -241,9 +241,9 @@ Task OnAddToRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRe /// /// /// - /// The original resource as retrieved from the underlying data store. The indication "left" specifies that is - /// declared on . Be aware that for performance reasons, not the full relationship is populated, but only the subset of - /// resources to be removed. + /// Identifier of the left resource. The indication "left" specifies that is declared on + /// . In contrast to other relationship methods, only the left ID and only the subset of right resources to be removed + /// are retrieved from the underlying data store. /// /// /// The to-many relationship being removed from. diff --git a/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs b/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs index 55f32ead40..df0061a5aa 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs @@ -11,6 +11,15 @@ namespace JsonApiDotNetCore.Resources; /// public interface IResourceDefinitionAccessor { + /// + /// Indicates whether this request targets only fetching of data (resources and relationships), as opposed to applying changes. + /// + /// + /// This property was added to reduce the impact of taking a breaking change. It will likely be removed in the next major version. + /// + [Obsolete("Use IJsonApiRequest.IsReadOnly.")] + bool IsReadOnlyRequest { get; } + /// /// Invokes for the specified resource type. /// diff --git a/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs b/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs index 89ba115a64..bcb9648320 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs @@ -1,4 +1,3 @@ -using System.Text.Json; using JetBrains.Annotations; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources.Annotations; @@ -13,9 +12,9 @@ public sealed class ResourceChangeTracker : IResourceChangeTracker? _initiallyStoredAttributeValues; - private IDictionary? _requestAttributeValues; - private IDictionary? _finallyStoredAttributeValues; + private IDictionary? _initiallyStoredAttributeValues; + private IDictionary? _requestAttributeValues; + private IDictionary? _finallyStoredAttributeValues; public ResourceChangeTracker(IJsonApiRequest request, ITargetedFields targetedFields) { @@ -50,15 +49,14 @@ public void SetFinallyStoredAttributeValues(TResource resource) _finallyStoredAttributeValues = CreateAttributeDictionary(resource, _request.PrimaryResourceType!.Attributes); } - private IDictionary CreateAttributeDictionary(TResource resource, IEnumerable attributes) + private IDictionary CreateAttributeDictionary(TResource resource, IEnumerable attributes) { - var result = new Dictionary(); + var result = new Dictionary(); foreach (AttrAttribute attribute in attributes) { object? value = attribute.GetValue(resource); - string json = JsonSerializer.Serialize(value); - result.Add(attribute.PublicName, json); + result.Add(attribute.PublicName, value); } return result; @@ -71,21 +69,21 @@ public bool HasImplicitChanges() { foreach (string key in _initiallyStoredAttributeValues.Keys) { - if (_requestAttributeValues.TryGetValue(key, out string? requestValue)) + if (_requestAttributeValues.TryGetValue(key, out object? requestValue)) { - string actualValue = _finallyStoredAttributeValues[key]; + object? actualValue = _finallyStoredAttributeValues[key]; - if (requestValue != actualValue) + if (!Equals(requestValue, actualValue)) { return true; } } else { - string initiallyStoredValue = _initiallyStoredAttributeValues[key]; - string finallyStoredValue = _finallyStoredAttributeValues[key]; + object? initiallyStoredValue = _initiallyStoredAttributeValues[key]; + object? finallyStoredValue = _finallyStoredAttributeValues[key]; - if (initiallyStoredValue != finallyStoredValue) + if (!Equals(initiallyStoredValue, finallyStoredValue)) { return true; } diff --git a/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs b/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs index 79b48c99eb..6b7ac6625b 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs @@ -15,6 +15,16 @@ public class ResourceDefinitionAccessor : IResourceDefinitionAccessor private readonly IResourceGraph _resourceGraph; private readonly IServiceProvider _serviceProvider; + /// + public bool IsReadOnlyRequest + { + get + { + var request = _serviceProvider.GetRequiredService(); + return request.IsReadOnly; + } + } + public ResourceDefinitionAccessor(IResourceGraph resourceGraph, IServiceProvider serviceProvider) { ArgumentGuard.NotNull(resourceGraph); diff --git a/src/JsonApiDotNetCore/Resources/SortExpressionLambdaConverter.cs b/src/JsonApiDotNetCore/Resources/SortExpressionLambdaConverter.cs index f1efa204d4..8696195e28 100644 --- a/src/JsonApiDotNetCore/Resources/SortExpressionLambdaConverter.cs +++ b/src/JsonApiDotNetCore/Resources/SortExpressionLambdaConverter.cs @@ -69,7 +69,7 @@ private static Expression SkipConvert(Expression expression) private static (Expression? innerExpression, bool isCount) TryReadCount(Expression expression) { - if (expression is MethodCallExpression methodCallExpression && methodCallExpression.Method.Name == "Count") + if (expression is MethodCallExpression { Method.Name: "Count" } methodCallExpression) { if (methodCallExpression.Arguments.Count <= 1) { @@ -81,7 +81,7 @@ private static (Expression? innerExpression, bool isCount) TryReadCount(Expressi if (expression is MemberExpression memberExpression) { - if (memberExpression.Member.MemberType == MemberTypes.Property && memberExpression.Member.Name is "Count" or "Length") + if (memberExpression.Member is { MemberType: MemberTypes.Property, Name: "Count" or "Length" }) { if (memberExpression.Member.GetCustomAttribute() == null) { @@ -114,7 +114,7 @@ private static (Expression? innerExpression, bool isCount) TryReadCount(Expressi private Expression? ReadAttribute(Expression expression) { - if (expression is MemberExpression { Expression: { } } memberExpression) + if (expression is MemberExpression { Expression: not null } memberExpression) { ResourceType resourceType = _resourceGraph.GetResourceType(memberExpression.Expression.Type); AttrAttribute? attribute = resourceType.FindAttributeByPropertyName(memberExpression.Member.Name); diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs index e4c0df21df..6964427680 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs @@ -131,7 +131,7 @@ private static void AssertHasNoLid(ResourceIdentity identity, RequestAdapterStat private static void AssertNoIdWithLid(ResourceIdentity identity, RequestAdapterState state) { - if (identity.Id != null && identity.Lid != null) + if (identity is { Id: not null, Lid: not null }) { throw new ModelConversionException(state.Position, "The 'id' and 'lid' element are mutually exclusive.", null); } diff --git a/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs b/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs index fb86eb084c..7550cbf761 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs @@ -80,12 +80,12 @@ private static string NoAsyncSuffix(string actionName) links.Self = GetLinkForTopLevelSelf(); } - if (_request.Kind == EndpointKind.Relationship && _request.Relationship != null && ShouldIncludeTopLevelLink(LinkTypes.Related, resourceType)) + if (_request is { Kind: EndpointKind.Relationship, Relationship: not null } && ShouldIncludeTopLevelLink(LinkTypes.Related, resourceType)) { links.Related = GetLinkForRelationshipRelated(_request.PrimaryId!, _request.Relationship); } - if (_request.IsCollection && _paginationContext.PageSize != null && ShouldIncludeTopLevelLink(LinkTypes.Paging, resourceType)) + if (_request.IsCollection && _paginationContext.PageSize != null && ShouldIncludeTopLevelLink(LinkTypes.Pagination, resourceType)) { SetPaginationInTopLevelLinks(resourceType!, links); } diff --git a/src/JsonApiDotNetCore/Serialization/Response/MetaBuilder.cs b/src/JsonApiDotNetCore/Serialization/Response/MetaBuilder.cs index 0da0ebe14b..59057c266b 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/MetaBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/MetaBuilder.cs @@ -30,7 +30,7 @@ public void Add(IDictionary values) { ArgumentGuard.NotNull(values); - _meta = values.Keys.Union(_meta.Keys).ToDictionary(key => key, key => values.ContainsKey(key) ? values[key] : _meta[key]); + _meta = values.Keys.Union(_meta.Keys).ToDictionary(key => key, key => values.TryGetValue(key, out object? value) ? value : _meta[key]); } /// diff --git a/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs b/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs index 4c3a44fe38..6226d6e597 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs @@ -181,13 +181,14 @@ public IList GetResponseIncluded() VisitRelationshipChildrenInSubtree(child, visited); } - List includes = visited.Select(node => node.ResourceObject).ToList(); + ISet primaryResourceObjectSet = GetDirectChildren().Select(node => node.ResourceObject).ToHashSet(ResourceObjectComparer.Instance); + List includes = new(); - foreach (ResourceObject primaryResourceObject in GetDirectChildren().Select(node => node.ResourceObject)) + foreach (ResourceObject include in visited.Select(node => node.ResourceObject)) { - if (includes.Contains(primaryResourceObject, ResourceObjectComparer.Instance)) + if (!primaryResourceObjectSet.Contains(include)) { - includes.Remove(primaryResourceObject); + includes.Add(include); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs index dcd38763c9..5aff75498d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs @@ -501,7 +501,7 @@ public async Task Cannot_create_resource_for_href_element() new { op = "add", - href = "/api/v1/musicTracks" + href = "/api/musicTracks" } } }; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs index 4baf6c7816..c2dc97b612 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs @@ -334,7 +334,7 @@ public async Task Cannot_delete_resource_for_href_element() new { op = "remove", - href = "/api/v1/musicTracks/1" + href = "/api/musicTracks/1" } } }; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs index 46ef0c4784..d5e74fa4c3 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs @@ -28,7 +28,9 @@ public AtomicTransactionConsistencyTests(IntegrationTestContext(); string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; - string dbConnectionString = $"Host=localhost;Port=5432;Database=JsonApiTest-Extra-{Guid.NewGuid():N};User ID=postgres;Password={postgresPassword}"; + + string dbConnectionString = + $"Host=localhost;Database=JsonApiTest-Extra-{Guid.NewGuid():N};User ID=postgres;Password={postgresPassword};Include Error Detail=true"; services.AddDbContext(options => options.UseNpgsql(dbConnectionString)); }); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs index 92f1bc638b..909467ad18 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs @@ -244,7 +244,7 @@ public async Task Cannot_add_for_href_element() new { op = "add", - href = "/api/v1/musicTracks/1/relationships/performers" + href = "/api/musicTracks/1/relationships/performers" } } }; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs index bed9b62d99..71b4a1bf09 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs @@ -243,7 +243,7 @@ public async Task Cannot_remove_for_href_element() new { op = "remove", - href = "/api/v1/musicTracks/1/relationships/performers" + href = "/api/musicTracks/1/relationships/performers" } } }; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs index be0da69ad9..f36144ce70 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs @@ -278,7 +278,7 @@ public async Task Cannot_replace_for_href_element() new { op = "update", - href = "/api/v1/musicTracks/1/relationships/performers" + href = "/api/musicTracks/1/relationships/performers" } } }; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs index 31b62a7c8e..026b4b0e1e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs @@ -531,7 +531,7 @@ public async Task Cannot_create_for_href_element() new { op = "update", - href = "/api/v1/musicTracks/1/relationships/ownedBy" + href = "/api/musicTracks/1/relationships/ownedBy" } } }; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs index 2371ab092a..4b0a10fd82 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs @@ -620,7 +620,7 @@ public async Task Cannot_update_resource_for_href_element() new { op = "update", - href = "/api/v1/musicTracks/1" + href = "/api/musicTracks/1" } } }; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs index d5b421850e..2c1e378e77 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs @@ -30,7 +30,7 @@ public CarExpressionRewriter(IResourceGraph resourceGraph) public override QueryExpression? VisitComparison(ComparisonExpression expression, object? argument) { - if (expression.Left is ResourceFieldChainExpression leftChain && expression.Right is LiteralConstantExpression rightConstant) + if (expression is { Left: ResourceFieldChainExpression leftChain, Right: LiteralConstantExpression rightConstant }) { PropertyInfo leftProperty = leftChain.Fields[^1].Property; @@ -41,7 +41,8 @@ public CarExpressionRewriter(IResourceGraph resourceGraph) throw new NotSupportedException("Only equality comparisons are possible on Car IDs."); } - return RewriteFilterOnCarStringIds(leftChain, rightConstant.Value.AsEnumerable()); + string carStringId = (string)rightConstant.TypedValue; + return RewriteFilterOnCarStringIds(leftChain, carStringId.AsEnumerable()); } } @@ -54,7 +55,7 @@ public CarExpressionRewriter(IResourceGraph resourceGraph) if (IsCarId(property)) { - string[] carStringIds = expression.Constants.Select(constant => constant.Value).ToArray(); + string[] carStringIds = expression.Constants.Select(constant => (string)constant.TypedValue).ToArray(); return RewriteFilterOnCarStringIds(expression.TargetAttribute, carStringIds); } @@ -100,7 +101,7 @@ private FilterExpression CreateEqualityComparisonOnCompositeKey(ResourceFieldCha string licensePlateValue) { ResourceFieldChainExpression regionIdChain = ReplaceLastAttributeInChain(existingCarIdChain, _regionIdAttribute); - var regionIdComparison = new ComparisonExpression(ComparisonOperator.Equals, regionIdChain, new LiteralConstantExpression(regionIdValue.ToString())); + var regionIdComparison = new ComparisonExpression(ComparisonOperator.Equals, regionIdChain, new LiteralConstantExpression(regionIdValue)); ResourceFieldChainExpression licensePlateChain = ReplaceLastAttributeInChain(existingCarIdChain, _licensePlateAttribute); var licensePlateComparison = new ComparisonExpression(ComparisonOperator.Equals, licensePlateChain, new LiteralConstantExpression(licensePlateValue)); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/NoModelStateValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/NoModelStateValidationTests.cs index 671123930e..6cd623ac94 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/NoModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/NoModelStateValidationTests.cs @@ -128,8 +128,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Failed to clear a required relationship."); - - error.Detail.Should().Be($"The relationship 'rootDirectory' on resource type 'systemVolumes' with ID '{existingVolume.StringId}' " + - "cannot be cleared because it is a required relationship."); + error.Detail.Should().Be("The relationship 'rootDirectory' on resource type 'systemVolumes' cannot be cleared because it is a required relationship."); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemFile.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemFile.cs index 2fa659b6ef..ef90adcc79 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemFile.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemFile.cs @@ -22,10 +22,10 @@ public sealed class SystemFile : Identifiable public long SizeInBytes { get; set; } [Attr] - [Range(typeof(DateOnly), "2000-01-01", "2050-01-01")] + [Range(typeof(DateOnly), "2000-01-01", "2050-01-01", ParseLimitsInInvariantCulture = true)] public DateOnly CreatedOn { get; set; } [Attr] - [Range(typeof(TimeOnly), "09:00:00", "17:30:00")] + [Range(typeof(TimeOnly), "09:00:00", "17:30:00", ParseLimitsInInvariantCulture = true)] public TimeOnly CreatedAt { get; set; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportTicket.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportTicket.cs index 619542ad6d..9ce245e951 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportTicket.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportTicket.cs @@ -10,4 +10,7 @@ public sealed class SupportTicket : Identifiable { [Attr] public string Description { get; set; } = null!; + + [HasOne] + public ProductFamily? ProductFamily { get; set; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs index eee8fa75e3..ff3360be30 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs @@ -1,5 +1,4 @@ using System.Net; -using System.Text.Json; using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; @@ -32,19 +31,21 @@ public TopLevelCountTests(IntegrationTestContext, } [Fact] - public async Task Renders_resource_count_for_collection() + public async Task Renders_resource_count_for_primary_resources_endpoint_with_filter() { // Arrange - SupportTicket ticket = _fakers.SupportTicket.Generate(); + List tickets = _fakers.SupportTicket.Generate(2); + + tickets[1].Description = "Update firmware version"; await _testContext.RunOnDatabaseAsync(async dbContext => { await dbContext.ClearTableAsync(); - dbContext.SupportTickets.Add(ticket); + dbContext.SupportTickets.AddRange(tickets); await dbContext.SaveChangesAsync(); }); - const string route = "/supportTickets"; + const string route = "/supportTickets?filter=startsWith(description,'Update ')"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -52,13 +53,33 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Meta.ShouldNotBeNull(); + responseDocument.Meta.Should().ContainTotal(1); + } - responseDocument.Meta.ShouldContainKey("total").With(value => + [Fact] + public async Task Renders_resource_count_for_secondary_resources_endpoint_with_filter() + { + // Arrange + ProductFamily family = _fakers.ProductFamily.Generate(); + family.Tickets = _fakers.SupportTicket.Generate(2); + + family.Tickets[1].Description = "Update firmware version"; + + await _testContext.RunOnDatabaseAsync(async dbContext => { - JsonElement element = value.Should().BeOfType().Subject; - element.GetInt32().Should().Be(1); + dbContext.ProductFamilies.Add(family); + await dbContext.SaveChangesAsync(); }); + + string route = $"/productFamilies/{family.StringId}/tickets?filter=contains(description,'firmware')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Meta.Should().ContainTotal(1); } [Fact] @@ -78,13 +99,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Meta.ShouldNotBeNull(); - - responseDocument.Meta.ShouldContainKey("total").With(value => - { - JsonElement element = value.Should().BeOfType().Subject; - element.GetInt32().Should().Be(0); - }); + responseDocument.Meta.Should().ContainTotal(0); } [Fact] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/KebabCasingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/KebabCasingTests.cs index ab0a4a4a7e..d70f50de0e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/KebabCasingTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/KebabCasingTests.cs @@ -1,5 +1,4 @@ using System.Net; -using System.Text.Json; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using TestBuildingBlocks; @@ -57,11 +56,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Included[0].Relationships.Should().BeNull(); responseDocument.Included[0].Links.ShouldNotBeNull().Self.Should().Be($"/public-api/diving-boards/{pools[1].DivingBoards[0].StringId}"); - responseDocument.Meta.ShouldContainKey("total").With(value => - { - JsonElement element = value.Should().BeOfType().Subject; - element.GetInt32().Should().Be(2); - }); + responseDocument.Meta.Should().ContainTotal(2); } [Fact] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs index b464a15083..b910b7e42e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs @@ -304,7 +304,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Cannot_filter_equality_on_incompatible_value() + public async Task Cannot_filter_equality_on_incompatible_values() { // Arrange var resource = new FilterableResource @@ -331,9 +331,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Query creation failed due to incompatible types."); + error.Title.Should().Be("The specified filter is invalid."); error.Detail.Should().Be("Failed to convert 'ABC' of type 'String' to type 'Int32'."); - error.Source.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Parameter.Should().Be("filter"); } [Theory] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs index b9fd8e2b2a..69dd7ca706 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs @@ -696,6 +696,28 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someString").With(value => value.Should().Be(resource.SomeString)); } + [Fact] + public async Task Cannot_filter_text_match_on_non_string_value() + { + // Arrange + const string route = "/filterableResources?filter=contains(someInt32,'123')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be("Attribute of type 'String' expected."); + error.Source.ShouldNotBeNull(); + error.Source.Parameter.Should().Be("filter"); + } + [Theory] [InlineData("yes", "no", "'yes'")] [InlineData("two", "one two", "'one','two','three'")] @@ -842,6 +864,28 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.ManyValue[0].Id.Should().Be(resource.StringId); } + [Fact] + public async Task Cannot_filter_on_count_with_incompatible_value() + { + // Arrange + const string route = "/filterableResources?filter=equals(count(children),'ABC')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be("Failed to convert 'ABC' of type 'String' to type 'Int32'."); + error.Source.ShouldNotBeNull(); + error.Source.Parameter.Should().Be("filter"); + } + [Theory] [InlineData("and(equals(someString,'ABC'),equals(someInt32,'11'))")] [InlineData("and(equals(someString,'ABC'),equals(someInt32,'11'),equals(someEnum,'Tuesday'))")] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs index 194e73a5dc..9174c84058 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs @@ -88,7 +88,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("The specified paging is invalid."); + error.Title.Should().Be("The specified pagination is invalid."); error.Detail.Should().Be("This query string parameter can only be used on a collection of resources (not on a single resource)."); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[number]"); @@ -185,7 +185,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("The specified paging is invalid."); + error.Title.Should().Be("The specified pagination is invalid."); error.Detail.Should().Be("This query string parameter can only be used on a collection of resources (not on a single resource)."); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[size]"); @@ -463,7 +463,7 @@ public async Task Cannot_paginate_in_unknown_scope() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("The specified paging is invalid."); + error.Title.Should().Be("The specified pagination is invalid."); error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource type 'webAccounts'."); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[number]"); @@ -485,7 +485,7 @@ public async Task Cannot_paginate_in_unknown_nested_scope() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("The specified paging is invalid."); + error.Title.Should().Be("The specified pagination is invalid."); error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' in 'posts.{Unknown.Relationship}' does not exist on resource type 'blogPosts'."); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[size]"); @@ -500,6 +500,7 @@ public async Task Uses_default_page_number_and_size() Blog blog = _fakers.Blog.Generate(); blog.Posts = _fakers.BlogPost.Generate(3); + blog.Posts.ToList().ForEach(post => post.Labels = _fakers.Label.Generate(3).ToHashSet()); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -507,7 +508,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/blogs/{blog.StringId}/posts"; + string route = $"/blogs/{blog.StringId}/posts?include=labels"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -519,16 +520,18 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.ManyValue[0].Id.Should().Be(blog.Posts[0].StringId); responseDocument.Data.ManyValue[1].Id.Should().Be(blog.Posts[1].StringId); + responseDocument.Included.ShouldHaveCount(4); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.First.Should().Be(responseDocument.Links.Self); - responseDocument.Links.Last.Should().Be($"{HostPrefix}{route}?page%5Bnumber%5D=2"); + responseDocument.Links.Last.Should().Be($"{responseDocument.Links.Self}&page%5Bnumber%5D=2"); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().Be(responseDocument.Links.Last); } [Fact] - public async Task Returns_all_resources_when_paging_is_disabled() + public async Task Returns_all_resources_when_pagination_is_disabled() { // Arrange var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationTests.cs index bcbd864d65..6b715f0825 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationTests.cs @@ -42,7 +42,7 @@ public async Task Cannot_use_negative_page_number() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("The specified paging is invalid."); + error.Title.Should().Be("The specified pagination is invalid."); error.Detail.Should().Be("Page number cannot be negative or zero."); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[number]"); @@ -64,7 +64,7 @@ public async Task Cannot_use_zero_page_number() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("The specified paging is invalid."); + error.Title.Should().Be("The specified pagination is invalid."); error.Detail.Should().Be("Page number cannot be negative or zero."); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[number]"); @@ -123,7 +123,7 @@ public async Task Cannot_use_negative_page_size() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("The specified paging is invalid."); + error.Title.Should().Be("The specified pagination is invalid."); error.Detail.Should().Be("Page size cannot be negative."); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[size]"); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationWithMaximumTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationWithMaximumTests.cs index cdbb9ea4be..66cb0dca57 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationWithMaximumTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationWithMaximumTests.cs @@ -71,7 +71,7 @@ public async Task Cannot_use_page_number_over_maximum() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("The specified paging is invalid."); + error.Title.Should().Be("The specified pagination is invalid."); error.Detail.Should().Be($"Page number cannot be higher than {MaximumPageNumber}."); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[number]"); @@ -93,7 +93,7 @@ public async Task Cannot_use_zero_page_size() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("The specified paging is invalid."); + error.Title.Should().Be("The specified pagination is invalid."); error.Detail.Should().Be("Page size cannot be unconstrained."); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[size]"); @@ -144,7 +144,7 @@ public async Task Cannot_use_page_size_over_maximum() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("The specified paging is invalid."); + error.Title.Should().Be("The specified pagination is invalid."); error.Detail.Should().Be($"Page size cannot be higher than {MaximumPageSize}."); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[size]"); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs index 4036c62f13..60855a80f2 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs @@ -791,7 +791,7 @@ public async Task Cannot_select_ToMany_relationship_with_blocked_capability() } [Fact] - public async Task Retrieves_all_properties_when_fieldset_contains_readonly_attribute() + public async Task Fetches_all_scalar_properties_when_fieldset_contains_readonly_attribute() { // Arrange var store = _testContext.Factory.Services.GetRequiredService(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs index 587b7d8277..da8c60a34f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs @@ -49,7 +49,7 @@ public async Task Sets_location_header_for_created_resource() } }; - const string route = "/workItems"; + const string route = "/workItems/"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -61,7 +61,7 @@ public async Task Sets_location_header_for_created_resource() httpResponse.Headers.Location.Should().Be($"/workItems/{newWorkItemId}"); responseDocument.Links.ShouldNotBeNull(); - responseDocument.Links.Self.Should().Be("http://localhost/workItems"); + responseDocument.Links.Self.Should().Be("http://localhost/workItems/"); responseDocument.Links.First.Should().BeNull(); responseDocument.Data.SingleValue.ShouldNotBeNull(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs index ad3be1d66f..487c6d72d5 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs @@ -349,7 +349,7 @@ public async Task Cannot_get_secondary_resource_for_unknown_primary_ID() } [Fact] - public async Task Cannot_get_secondary_resource_for_unknown_secondary_type() + public async Task Cannot_get_secondary_resource_for_unknown_relationship() { // Arrange WorkItem workItem = _fakers.WorkItem.Generate(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RequiredRelationships/DefaultBehaviorTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RequiredRelationships/DefaultBehaviorTests.cs index 3e1dfcaef0..5828de90d3 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/RequiredRelationships/DefaultBehaviorTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RequiredRelationships/DefaultBehaviorTests.cs @@ -256,9 +256,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Failed to clear a required relationship."); - - error.Detail.Should().Be($"The relationship 'customer' on resource type 'orders' with ID '{existingOrder.StringId}' " + - "cannot be cleared because it is a required relationship."); + error.Detail.Should().Be("The relationship 'customer' on resource type 'orders' cannot be cleared because it is a required relationship."); } [Fact] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/ResourceDefinitionReadTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/ResourceDefinitionReadTests.cs index db80d4c14b..6176b28a38 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/ResourceDefinitionReadTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/ResourceDefinitionReadTests.cs @@ -1,5 +1,4 @@ using System.Net; -using System.Text.Json; using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; @@ -238,11 +237,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.ManyValue[0].Id.Should().Be(planets[1].StringId); responseDocument.Data.ManyValue[1].Id.Should().Be(planets[3].StringId); - responseDocument.Meta.ShouldContainKey("total").With(value => - { - JsonElement element = value.Should().BeOfType().Subject; - element.GetInt32().Should().Be(2); - }); + responseDocument.Meta.Should().ContainTotal(2); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { @@ -297,11 +292,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(planets[3].StringId); - responseDocument.Meta.ShouldContainKey("total").With(value => - { - JsonElement element = value.Should().BeOfType().Subject; - element.GetInt32().Should().Be(1); - }); + responseDocument.Meta.Should().ContainTotal(1); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { @@ -349,11 +340,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.ManyValue[0].Id.Should().Be(star.Planets.ElementAt(1).StringId); responseDocument.Data.ManyValue[1].Id.Should().Be(star.Planets.ElementAt(3).StringId); - responseDocument.Meta.ShouldContainKey("total").With(value => - { - JsonElement element = value.Should().BeOfType().Subject; - element.GetInt32().Should().Be(2); - }); + responseDocument.Meta.Should().ContainTotal(2); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { @@ -405,11 +392,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.ManyValue[0].Id.Should().Be(star.Planets.ElementAt(1).StringId); responseDocument.Data.ManyValue[1].Id.Should().Be(star.Planets.ElementAt(3).StringId); - responseDocument.Meta.ShouldContainKey("total").With(value => - { - JsonElement element = value.Should().BeOfType().Subject; - element.GetInt32().Should().Be(2); - }); + responseDocument.Meta.Should().ContainTotal(2); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ResourceInheritanceReadTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ResourceInheritanceReadTests.cs index ebca28dac9..ee974e0756 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ResourceInheritanceReadTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ResourceInheritanceReadTests.cs @@ -614,9 +614,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => resource.Links.ShouldNotBeNull(); resource.Links.Self.Should().Be($"/chromeWheels/{resource.Id}"); - resource.Attributes.ShouldHaveCount(2); - resource.Attributes.ShouldContainKey("radius"); - resource.Attributes.ShouldContainKey("paintColor"); + resource.Attributes.ShouldOnlyContainKeys("radius", "paintColor"); } foreach (ResourceObject resource in responseDocument.Data.ManyValue.Where(value => value.Type == "carbonWheels")) @@ -624,9 +622,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => resource.Links.ShouldNotBeNull(); resource.Links.Self.Should().Be($"/carbonWheels/{resource.Id}"); - resource.Attributes.ShouldHaveCount(2); - resource.Attributes.ShouldContainKey("radius"); - resource.Attributes.ShouldContainKey("hasTube"); + resource.Attributes.ShouldOnlyContainKeys("radius", "hasTube"); } foreach (ResourceObject resource in responseDocument.Data.ManyValue) @@ -686,9 +682,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => resource.Links.ShouldNotBeNull(); resource.Links.Self.Should().Be($"/chromeWheels/{resource.Id}"); - resource.Attributes.ShouldHaveCount(2); - resource.Attributes.ShouldContainKey("radius"); - resource.Attributes.ShouldContainKey("paintColor"); + resource.Attributes.ShouldOnlyContainKeys("radius", "paintColor"); } foreach (ResourceObject resource in responseDocument.Data.ManyValue.Where(value => value.Type == "carbonWheels")) @@ -696,9 +690,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => resource.Links.ShouldNotBeNull(); resource.Links.Self.Should().Be($"/carbonWheels/{resource.Id}"); - resource.Attributes.ShouldHaveCount(2); - resource.Attributes.ShouldContainKey("radius"); - resource.Attributes.ShouldContainKey("hasTube"); + resource.Attributes.ShouldOnlyContainKeys("radius", "hasTube"); } foreach (ResourceObject resource in responseDocument.Data.ManyValue) @@ -752,9 +744,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => resource.Links.ShouldNotBeNull(); resource.Links.Self.Should().Be($"/chromeWheels/{resource.Id}"); - resource.Attributes.ShouldHaveCount(2); - resource.Attributes.ShouldContainKey("radius"); - resource.Attributes.ShouldContainKey("paintColor"); + resource.Attributes.ShouldOnlyContainKeys("radius", "paintColor"); } foreach (ResourceObject resource in responseDocument.Data.ManyValue.Where(value => value.Type == "carbonWheels")) @@ -762,9 +752,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => resource.Links.ShouldNotBeNull(); resource.Links.Self.Should().Be($"/carbonWheels/{resource.Id}"); - resource.Attributes.ShouldHaveCount(2); - resource.Attributes.ShouldContainKey("radius"); - resource.Attributes.ShouldContainKey("hasTube"); + resource.Attributes.ShouldOnlyContainKeys("radius", "hasTube"); } foreach (ResourceObject resource in responseDocument.Data.ManyValue) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/Address.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/Address.cs new file mode 100644 index 0000000000..97017706e0 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/Address.cs @@ -0,0 +1,12 @@ +using JetBrains.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.Serialization; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class Address +{ + public string Street { get; set; } = null!; + public string? ZipCode { get; set; } + public string City { get; set; } = null!; + public string Country { get; set; } = null!; +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/MeetingAttendee.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/MeetingAttendee.cs index 4580d21c52..118e0c9df5 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/MeetingAttendee.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/MeetingAttendee.cs @@ -11,6 +11,9 @@ public sealed class MeetingAttendee : Identifiable [Attr] public string DisplayName { get; set; } = null!; + [Attr] + public Address HomeAddress { get; set; } = null!; + [HasOne] public Meeting? Meeting { get; set; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationDbContext.cs index 885c3b950a..32e093214c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationDbContext.cs @@ -2,6 +2,8 @@ using Microsoft.EntityFrameworkCore; using TestBuildingBlocks; +// @formatter:wrap_chained_method_calls chop_always + namespace JsonApiDotNetCoreTests.IntegrationTests.Serialization; [UsedImplicitly(ImplicitUseTargetFlags.Members)] @@ -14,4 +16,12 @@ public SerializationDbContext(DbContextOptions options) : base(options) { } + + protected override void OnModelCreating(ModelBuilder builder) + { + builder.Entity() + .OwnsOne(meetingAttendee => meetingAttendee.HomeAddress); + + base.OnModelCreating(builder); + } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationFakers.cs index a7dded542c..6f327deef4 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationFakers.cs @@ -29,7 +29,14 @@ internal sealed class SerializationFakers : FakerContainer private readonly Lazy> _lazyMeetingAttendeeFaker = new(() => new Faker() .UseSeed(GetFakerSeed()) - .RuleFor(attendee => attendee.DisplayName, faker => faker.Random.Utf16String())); + .RuleFor(attendee => attendee.DisplayName, faker => faker.Random.Utf16String()) + .RuleFor(attendee => attendee.HomeAddress, faker => new Address + { + Street = faker.Address.StreetAddress(), + ZipCode = faker.Address.ZipCode(), + City = faker.Address.City(), + Country = faker.Address.Country() + })); public Faker Meeting => _lazyMeetingFaker.Value; public Faker MeetingAttendee => _lazyMeetingAttendeeFaker.Value; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationTests.cs index 63eeabb4c9..efe7f1353c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationTests.cs @@ -142,7 +142,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ""type"": ""meetingAttendees"", ""id"": """ + meeting.Attendees[0].StringId + @""", ""attributes"": { - ""displayName"": """ + meeting.Attendees[0].DisplayName + @""" + ""displayName"": """ + meeting.Attendees[0].DisplayName + @""", + ""homeAddress"": { + ""street"": """ + meeting.Attendees[0].HomeAddress.Street + @""", + ""zipCode"": """ + meeting.Attendees[0].HomeAddress.ZipCode + @""", + ""city"": """ + meeting.Attendees[0].HomeAddress.City + @""", + ""country"": """ + meeting.Attendees[0].HomeAddress.Country + @""" + } }, ""relationships"": { ""meeting"": { @@ -191,7 +197,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ""type"": ""meetingAttendees"", ""id"": """ + attendee.StringId + @""", ""attributes"": { - ""displayName"": """ + attendee.DisplayName + @""" + ""displayName"": """ + attendee.DisplayName + @""", + ""homeAddress"": { + ""street"": """ + attendee.HomeAddress.Street + @""", + ""zipCode"": """ + attendee.HomeAddress.ZipCode + @""", + ""city"": """ + attendee.HomeAddress.City + @""", + ""country"": """ + attendee.HomeAddress.Country + @""" + } }, ""relationships"": { ""meeting"": { @@ -465,7 +477,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ""type"": ""meetingAttendees"", ""id"": """ + meeting.Attendees[0].StringId + @""", ""attributes"": { - ""displayName"": """ + meeting.Attendees[0].DisplayName + @""" + ""displayName"": """ + meeting.Attendees[0].DisplayName + @""", + ""homeAddress"": { + ""street"": """ + meeting.Attendees[0].HomeAddress.Street + @""", + ""zipCode"": """ + meeting.Attendees[0].HomeAddress.ZipCode + @""", + ""city"": """ + meeting.Attendees[0].HomeAddress.City + @""", + ""country"": """ + meeting.Attendees[0].HomeAddress.Country + @""" + } }, ""relationships"": { ""meeting"": { @@ -704,7 +722,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ""type"": ""meetingAttendees"", ""id"": """ + existingAttendee.StringId + @""", ""attributes"": { - ""displayName"": """ + existingAttendee.DisplayName + @""" + ""displayName"": """ + existingAttendee.DisplayName + @""", + ""homeAddress"": { + ""street"": """ + existingAttendee.HomeAddress.Street + @""", + ""zipCode"": """ + existingAttendee.HomeAddress.ZipCode + @""", + ""city"": """ + existingAttendee.HomeAddress.City + @""", + ""country"": """ + existingAttendee.HomeAddress.Country + @""" + } }, ""relationships"": { ""meeting"": { diff --git a/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj b/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj index 3bd3632461..8c4822b67a 100644 --- a/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj +++ b/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj @@ -1,4 +1,4 @@ - + $(TargetFrameworkName) @@ -11,7 +11,7 @@ - + diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs index cbc63de58f..cc4583ed4c 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs @@ -21,37 +21,37 @@ public sealed class LinkInclusionTests [InlineData(LinkTypes.NotConfigured, LinkTypes.None, LinkTypes.None)] [InlineData(LinkTypes.NotConfigured, LinkTypes.Self, LinkTypes.Self)] [InlineData(LinkTypes.NotConfigured, LinkTypes.Related, LinkTypes.Related)] - [InlineData(LinkTypes.NotConfigured, LinkTypes.Paging, LinkTypes.Paging)] + [InlineData(LinkTypes.NotConfigured, LinkTypes.Pagination, LinkTypes.Pagination)] [InlineData(LinkTypes.NotConfigured, LinkTypes.All, LinkTypes.All)] [InlineData(LinkTypes.None, LinkTypes.NotConfigured, LinkTypes.None)] [InlineData(LinkTypes.None, LinkTypes.None, LinkTypes.None)] [InlineData(LinkTypes.None, LinkTypes.Self, LinkTypes.None)] [InlineData(LinkTypes.None, LinkTypes.Related, LinkTypes.None)] - [InlineData(LinkTypes.None, LinkTypes.Paging, LinkTypes.None)] + [InlineData(LinkTypes.None, LinkTypes.Pagination, LinkTypes.None)] [InlineData(LinkTypes.None, LinkTypes.All, LinkTypes.None)] [InlineData(LinkTypes.Self, LinkTypes.NotConfigured, LinkTypes.Self)] [InlineData(LinkTypes.Self, LinkTypes.None, LinkTypes.Self)] [InlineData(LinkTypes.Self, LinkTypes.Self, LinkTypes.Self)] [InlineData(LinkTypes.Self, LinkTypes.Related, LinkTypes.Self)] - [InlineData(LinkTypes.Self, LinkTypes.Paging, LinkTypes.Self)] + [InlineData(LinkTypes.Self, LinkTypes.Pagination, LinkTypes.Self)] [InlineData(LinkTypes.Self, LinkTypes.All, LinkTypes.Self)] [InlineData(LinkTypes.Related, LinkTypes.NotConfigured, LinkTypes.Related)] [InlineData(LinkTypes.Related, LinkTypes.None, LinkTypes.Related)] [InlineData(LinkTypes.Related, LinkTypes.Self, LinkTypes.Related)] [InlineData(LinkTypes.Related, LinkTypes.Related, LinkTypes.Related)] - [InlineData(LinkTypes.Related, LinkTypes.Paging, LinkTypes.Related)] + [InlineData(LinkTypes.Related, LinkTypes.Pagination, LinkTypes.Related)] [InlineData(LinkTypes.Related, LinkTypes.All, LinkTypes.Related)] - [InlineData(LinkTypes.Paging, LinkTypes.NotConfigured, LinkTypes.Paging)] - [InlineData(LinkTypes.Paging, LinkTypes.None, LinkTypes.Paging)] - [InlineData(LinkTypes.Paging, LinkTypes.Self, LinkTypes.Paging)] - [InlineData(LinkTypes.Paging, LinkTypes.Related, LinkTypes.Paging)] - [InlineData(LinkTypes.Paging, LinkTypes.Paging, LinkTypes.Paging)] - [InlineData(LinkTypes.Paging, LinkTypes.All, LinkTypes.Paging)] + [InlineData(LinkTypes.Pagination, LinkTypes.NotConfigured, LinkTypes.Pagination)] + [InlineData(LinkTypes.Pagination, LinkTypes.None, LinkTypes.Pagination)] + [InlineData(LinkTypes.Pagination, LinkTypes.Self, LinkTypes.Pagination)] + [InlineData(LinkTypes.Pagination, LinkTypes.Related, LinkTypes.Pagination)] + [InlineData(LinkTypes.Pagination, LinkTypes.Pagination, LinkTypes.Pagination)] + [InlineData(LinkTypes.Pagination, LinkTypes.All, LinkTypes.Pagination)] [InlineData(LinkTypes.All, LinkTypes.NotConfigured, LinkTypes.All)] [InlineData(LinkTypes.All, LinkTypes.None, LinkTypes.All)] [InlineData(LinkTypes.All, LinkTypes.Self, LinkTypes.All)] [InlineData(LinkTypes.All, LinkTypes.Related, LinkTypes.All)] - [InlineData(LinkTypes.All, LinkTypes.Paging, LinkTypes.All)] + [InlineData(LinkTypes.All, LinkTypes.Pagination, LinkTypes.All)] [InlineData(LinkTypes.All, LinkTypes.All, LinkTypes.All)] public void Applies_cascading_settings_for_top_level_links(LinkTypes linksInResourceType, LinkTypes linksInOptions, LinkTypes expected) { @@ -117,7 +117,7 @@ public void Applies_cascading_settings_for_top_level_links(LinkTypes linksInReso topLevelLinks.Related.Should().BeNull(); } - if (expected.HasFlag(LinkTypes.Paging)) + if (expected.HasFlag(LinkTypes.Pagination)) { topLevelLinks.First.ShouldNotBeNull(); topLevelLinks.Last.ShouldNotBeNull(); diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/PaginationParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/PaginationParseTests.cs index 5dc6e3ab75..615bae48e2 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/PaginationParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/PaginationParseTests.cs @@ -82,7 +82,7 @@ public void Reader_Read_Page_Number_Fails(string parameterValue, string errorMes ErrorObject error = exception.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("The specified paging is invalid."); + error.Title.Should().Be("The specified pagination is invalid."); error.Detail.Should().Be(errorMessage); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[number]"); @@ -118,7 +118,7 @@ public void Reader_Read_Page_Size_Fails(string parameterValue, string errorMessa ErrorObject error = exception.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("The specified paging is invalid."); + error.Title.Should().Be("The specified pagination is invalid."); error.Detail.Should().Be(errorMessage); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[size]"); diff --git a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/HasManyAttributeTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/HasManyAttributeTests.cs new file mode 100644 index 0000000000..66b6dd0c81 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/HasManyAttributeTests.cs @@ -0,0 +1,141 @@ +using FluentAssertions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.UnitTests.ResourceGraph; + +public sealed class HasManyAttributeTests +{ + [Fact] + public void Cannot_set_value_to_null() + { + // Arrange + var attribute = new HasManyAttribute + { + Property = typeof(TestResource).GetProperty(nameof(TestResource.Children))! + }; + + var resource = new TestResource(); + + // Act + Action action = () => attribute.SetValue(resource, null); + + // Assert + action.Should().ThrowExactly(); + } + + [Fact] + public void Cannot_set_value_to_primitive_type() + { + // Arrange + var attribute = new HasManyAttribute + { + Property = typeof(TestResource).GetProperty(nameof(TestResource.Children))! + }; + + var resource = new TestResource(); + + // Act + Action action = () => attribute.SetValue(resource, 1); + + // Assert + action.Should().ThrowExactly().WithMessage("Resource of type 'System.Int32' must be a collection."); + } + + [Fact] + public void Cannot_set_value_to_single_resource() + { + // Arrange + var attribute = new HasManyAttribute + { + Property = typeof(TestResource).GetProperty(nameof(TestResource.Children))! + }; + + var resource = new TestResource(); + + // Act + Action action = () => attribute.SetValue(resource, resource); + + // Assert + action.Should().ThrowExactly().WithMessage($"Resource of type '{typeof(TestResource).FullName}' must be a collection."); + } + + [Fact] + public void Can_set_value_to_collection_with_single_resource() + { + // Arrange + var attribute = new HasManyAttribute + { + Property = typeof(TestResource).GetProperty(nameof(TestResource.Children))! + }; + + var resource = new TestResource(); + + var children = new List + { + resource + }; + + // Act + attribute.SetValue(resource, children); + + // Assert + attribute.GetValue(resource).Should().BeOfType>().Subject.ShouldHaveCount(1); + } + + [Fact] + public void Cannot_set_value_to_collection_with_null_element() + { + // Arrange + var attribute = new HasManyAttribute + { + Property = typeof(TestResource).GetProperty(nameof(TestResource.Children))! + }; + + var resource = new TestResource(); + + var children = new List + { + resource, + null! + }; + + // Act + Action action = () => attribute.SetValue(resource, children); + + // Assert + action.Should().ThrowExactly().WithMessage("Resource collection must not contain null values."); + } + + [Fact] + public void Cannot_set_value_to_collection_with_primitive_element() + { + // Arrange + var attribute = new HasManyAttribute + { + Property = typeof(TestResource).GetProperty(nameof(TestResource.Children))! + }; + + var resource = new TestResource(); + + var children = new List + { + resource, + 1 + }; + + // Act + Action action = () => attribute.SetValue(resource, children); + + // Assert + action.Should().ThrowExactly().WithMessage("Resource of type 'System.Int32' does not implement IIdentifiable."); + } + + private sealed class TestResource : Identifiable + { + [HasMany] + public IEnumerable Children { get; set; } = new HashSet(); + } +} diff --git a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/HasOneAttributeTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/HasOneAttributeTests.cs new file mode 100644 index 0000000000..bd2b44c418 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/HasOneAttributeTests.cs @@ -0,0 +1,69 @@ +using FluentAssertions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using Xunit; + +namespace JsonApiDotNetCoreTests.UnitTests.ResourceGraph; + +public sealed class HasOneAttributeTests +{ + [Fact] + public void Can_set_value_to_null() + { + // Arrange + var attribute = new HasOneAttribute + { + Property = typeof(TestResource).GetProperty(nameof(TestResource.Parent))! + }; + + var resource = new TestResource(); + + // Act + attribute.SetValue(resource, null); + + // Assert + attribute.GetValue(resource).Should().BeNull(); + } + + [Fact] + public void Can_set_value_to_self() + { + // Arrange + var attribute = new HasOneAttribute + { + Property = typeof(TestResource).GetProperty(nameof(TestResource.Parent))! + }; + + var resource = new TestResource(); + + // Act + attribute.SetValue(resource, resource); + + // Assert + attribute.GetValue(resource).Should().Be(resource); + } + + [Fact] + public void Cannot_set_value_to_primitive_type() + { + // Arrange + var attribute = new HasOneAttribute + { + Property = typeof(TestResource).GetProperty(nameof(TestResource.Parent))! + }; + + var resource = new TestResource(); + + // Act + Action action = () => attribute.SetValue(resource, 1); + + // Assert + action.Should().ThrowExactly().WithMessage("Resource of type 'System.Int32' does not implement IIdentifiable."); + } + + private sealed class TestResource : Identifiable + { + [HasOne] + public TestResource? Parent { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceFieldAttributeTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceFieldAttributeTests.cs new file mode 100644 index 0000000000..b7a028bd6e --- /dev/null +++ b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceFieldAttributeTests.cs @@ -0,0 +1,254 @@ +using System.Reflection; +using FluentAssertions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.AspNetCore.Http; +using Xunit; + +namespace JsonApiDotNetCoreTests.UnitTests.ResourceGraph; + +public sealed class ResourceFieldAttributeTests +{ + [Fact] + public void Cannot_set_public_name_to_null() + { + // Arrange + var attribute = new AttrAttribute(); + + // Act + Action action = () => attribute.PublicName = null!; + + // Assert + action.Should().ThrowExactly().WithMessage("Exposed name cannot be null, empty or contain only whitespace. (Parameter 'value')"); + } + + [Fact] + public void Cannot_set_public_name_to_empty() + { + // Arrange + var attribute = new AttrAttribute(); + + // Act + Action action = () => attribute.PublicName = string.Empty; + + // Assert + action.Should().ThrowExactly().WithMessage("Exposed name cannot be null, empty or contain only whitespace. (Parameter 'value')"); + } + + [Fact] + public void Cannot_set_public_name_to_whitespace() + { + // Arrange + var attribute = new AttrAttribute(); + + // Act + Action action = () => attribute.PublicName = " "; + + // Assert + action.Should().ThrowExactly().WithMessage("Exposed name cannot be null, empty or contain only whitespace. (Parameter 'value')"); + } + + [Fact] + public void Cannot_get_value_for_null() + { + // Arrange + var attribute = new AttrAttribute(); + + // Act + Action action = () => attribute.GetValue(null!); + + // Assert + action.Should().ThrowExactly(); + } + + [Fact] + public void Cannot_get_value_for_primitive_type() + { + // Arrange + var attribute = new AttrAttribute(); + + // Act + Action action = () => attribute.GetValue(1); + + // Assert + action.Should().ThrowExactly().WithMessage("Resource of type 'System.Int32' does not implement IIdentifiable."); + } + + [Fact] + public void Cannot_get_value_for_write_only_resource_property() + { + // Arrange + var attribute = new AttrAttribute + { + Property = typeof(TestResource).GetProperty(nameof(TestResource.WriteOnlyAttribute))! + }; + + var resource = new TestResource(); + + // Act + Action action = () => attribute.GetValue(resource); + + // Assert + action.Should().ThrowExactly().WithMessage("Property 'TestResource.WriteOnlyAttribute' is write-only."); + } + + [Fact] + public void Cannot_get_value_for_unknown_resource_property() + { + // Arrange + var attribute = new AttrAttribute + { + Property = typeof(IHttpContextAccessor).GetProperty(nameof(IHttpContextAccessor.HttpContext))! + }; + + var resource = new TestResource(); + + // Act + Action action = () => attribute.GetValue(resource); + + // Assert + action.Should().ThrowExactly() + .WithMessage("Unable to get property value of 'IHttpContextAccessor.HttpContext' on instance of type 'TestResource'.") + .WithInnerException(); + } + + [Fact] + public void Cannot_get_value_for_throwing_resource_property() + { + // Arrange + var attribute = new AttrAttribute + { + Property = typeof(TestResource).GetProperty(nameof(TestResource.ThrowOnGetAttribute))! + }; + + var resource = new TestResource(); + + // Act + Action action = () => attribute.GetValue(resource); + + // Assert + action.Should().ThrowExactly().WithInnerException().WithMessage("Getting value is not supported."); + } + + [Fact] + public void Cannot_set_value_for_null() + { + // Arrange + var attribute = new AttrAttribute(); + + // Act + Action action = () => attribute.SetValue(null!, "some"); + + // Assert + action.Should().ThrowExactly(); + } + + [Fact] + public void Cannot_set_value_for_primitive_type() + { + // Arrange + var attribute = new AttrAttribute(); + + // Act + Action action = () => attribute.SetValue(1, "some"); + + // Assert + action.Should().ThrowExactly().WithMessage("Resource of type 'System.Int32' does not implement IIdentifiable."); + } + + [Fact] + public void Cannot_set_value_for_read_only_resource_property() + { + // Arrange + var attribute = new AttrAttribute + { + Property = typeof(TestResource).GetProperty(nameof(TestResource.ReadOnlyAttribute))! + }; + + var resource = new TestResource(); + + // Act + Action action = () => attribute.SetValue(resource, true); + + // Assert + action.Should().ThrowExactly().WithMessage("Property 'TestResource.ReadOnlyAttribute' is read-only."); + } + + [Fact] + public void Cannot_set_value_for_unknown_resource_property() + { + // Arrange + var attribute = new AttrAttribute + { + Property = typeof(IHttpContextAccessor).GetProperty(nameof(IHttpContextAccessor.HttpContext))! + }; + + var resource = new TestResource(); + + // Act + Action action = () => attribute.SetValue(resource, "some"); + + // Assert + action.Should().ThrowExactly() + .WithMessage("Unable to set property value of 'IHttpContextAccessor.HttpContext' on instance of type 'TestResource'.") + .WithInnerException(); + } + + [Fact] + public void Cannot_set_value_for_throwing_resource_property() + { + // Arrange + var attribute = new AttrAttribute + { + Property = typeof(TestResource).GetProperty(nameof(TestResource.ThrowOnSetAttribute))! + }; + + var resource = new TestResource(); + + // Act + Action action = () => attribute.SetValue(resource, 1); + + // Assert + action.Should().ThrowExactly().WithInnerException().WithMessage("Setting value is not supported."); + } + + [Fact] + public void Cannot_set_value_to_incompatible_value() + { + // Arrange + var attribute = new AttrAttribute + { + Property = typeof(TestResource).GetProperty(nameof(TestResource.WriteOnlyAttribute))! + }; + + var resource = new TestResource(); + + // Act + Action action = () => attribute.SetValue(resource, DateTime.UtcNow); + + // Assert + action.Should().ThrowExactly().WithMessage("Object of type 'System.DateTime' cannot be converted to type 'System.Boolean'."); + } + + private sealed class TestResource : Identifiable + { + [Attr] + public bool ReadOnlyAttribute => true; + + [Attr] + public bool WriteOnlyAttribute + { + set => _ = value; + } + + [Attr] + public int ThrowOnGetAttribute => throw new NotSupportedException("Getting value is not supported."); + + [Attr] + public int ThrowOnSetAttribute + { + get => 1; + set => throw new NotSupportedException("Setting value is not supported."); + } + } +} diff --git a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs index 3fc221c190..97a35603b3 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs @@ -361,7 +361,7 @@ public void Can_override_capabilities_on_Id_property() IResourceGraph resourceGraph = builder.Build(); ResourceType resourceType = resourceGraph.GetResourceType(); - AttrAttribute idAttribute = resourceType.Attributes.Single(attr => attr.Property.Name == nameof(Identifiable.Id)); + AttrAttribute idAttribute = resourceType.GetAttributeByPropertyName(nameof(Identifiable.Id)); idAttribute.Capabilities.Should().Be(AttrCapabilities.AllowFilter); } diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/FakeResourceDefinitionAccessor.cs b/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/FakeResourceDefinitionAccessor.cs index 8cd108deae..6126b9d744 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/FakeResourceDefinitionAccessor.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/FakeResourceDefinitionAccessor.cs @@ -9,6 +9,8 @@ namespace JsonApiDotNetCoreTests.UnitTests.Serialization.Response; internal sealed class FakeResourceDefinitionAccessor : IResourceDefinitionAccessor { + bool IResourceDefinitionAccessor.IsReadOnlyRequest => throw new NotImplementedException(); + public IImmutableSet OnApplyIncludes(ResourceType resourceType, IImmutableSet existingIncludes) { return existingIncludes; diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/IncompleteResourceGraphTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/IncompleteResourceGraphTests.cs index 4384d6aaa7..247da8d6a3 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/IncompleteResourceGraphTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/IncompleteResourceGraphTests.cs @@ -34,7 +34,7 @@ public void Fails_when_derived_type_is_missing_in_resource_graph() var resourceDefinitionAccessor = new FakeResourceDefinitionAccessor(); var sparseFieldSetCache = new SparseFieldSetCache(Array.Empty(), resourceDefinitionAccessor); var requestQueryStringAccessor = new FakeRequestQueryStringAccessor(); - var evaluatedIncludeCache = new EvaluatedIncludeCache(); + var evaluatedIncludeCache = new EvaluatedIncludeCache(Array.Empty()); var responseModelAdapter = new ResponseModelAdapter(request, options, linkBuilder, metaBuilder, resourceDefinitionAccessor, evaluatedIncludeCache, sparseFieldSetCache, requestQueryStringAccessor); diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/ResponseModelAdapterTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/ResponseModelAdapterTests.cs index 39279181dd..8bd13a941a 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/ResponseModelAdapterTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/ResponseModelAdapterTests.cs @@ -569,7 +569,7 @@ private ResponseModelAdapter CreateAdapter(IJsonApiOptions options, string? prim PrimaryId = primaryId }; - var evaluatedIncludeCache = new EvaluatedIncludeCache(); + var evaluatedIncludeCache = new EvaluatedIncludeCache(Array.Empty()); var linkBuilder = new FakeLinkBuilder(); var metaBuilder = new FakeMetaBuilder(); var resourceDefinitionAccessor = new FakeResourceDefinitionAccessor(); diff --git a/test/MultiDbContextTests/MultiDbContextTests.csproj b/test/MultiDbContextTests/MultiDbContextTests.csproj index b08c25805f..5ec0b1400c 100644 --- a/test/MultiDbContextTests/MultiDbContextTests.csproj +++ b/test/MultiDbContextTests/MultiDbContextTests.csproj @@ -12,6 +12,5 @@ - diff --git a/test/MultiDbContextTests/ResourceTests.cs b/test/MultiDbContextTests/ResourceTests.cs index 3452b6aea9..ed25d0f274 100644 --- a/test/MultiDbContextTests/ResourceTests.cs +++ b/test/MultiDbContextTests/ResourceTests.cs @@ -3,7 +3,6 @@ using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; using MultiDbContextExample.Models; using TestBuildingBlocks; @@ -11,9 +10,9 @@ namespace MultiDbContextTests; -public sealed class ResourceTests : IntegrationTest, IClassFixture> +public sealed class ResourceTests : IntegrationTest, IClassFixture> { - private readonly WebApplicationFactory _factory; + private readonly NoLoggingWebApplicationFactory _factory; protected override JsonSerializerOptions SerializerOptions { @@ -24,7 +23,7 @@ protected override JsonSerializerOptions SerializerOptions } } - public ResourceTests(WebApplicationFactory factory) + public ResourceTests(NoLoggingWebApplicationFactory factory) { _factory = factory; } @@ -33,7 +32,7 @@ public ResourceTests(WebApplicationFactory factory) public async Task Can_get_ResourceAs() { // Arrange - const string route = "/resourceAs"; + const string route = "/api/resourceAs"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); @@ -49,7 +48,7 @@ public async Task Can_get_ResourceAs() public async Task Can_get_ResourceBs() { // Arrange - const string route = "/resourceBs"; + const string route = "/api/resourceBs"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); diff --git a/test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj b/test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj index d9d8b80ad8..b0c4838b1a 100644 --- a/test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj +++ b/test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj @@ -12,6 +12,5 @@ - diff --git a/test/NoEntityFrameworkTests/NullSafeExpressionRewriterTests.cs b/test/NoEntityFrameworkTests/NullSafeExpressionRewriterTests.cs new file mode 100644 index 0000000000..57da032819 --- /dev/null +++ b/test/NoEntityFrameworkTests/NullSafeExpressionRewriterTests.cs @@ -0,0 +1,881 @@ +using System.Linq.Expressions; +using FluentAssertions; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using NoEntityFrameworkExample; +using Xunit; + +namespace NoEntityFrameworkTests; + +public sealed class NullSafeExpressionRewriterTests +{ + [Fact] + public void Can_rewrite_where_clause_with_constant_comparison() + { + // Arrange + var generator = new IdGenerator(); + + var dataSource = new List + { + new() + { + Id = generator.GetNext() + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + } + } + }; + + TestResource lastInDataSource = dataSource.Last(); + + Expression, IEnumerable>> expression = source => source.Where(resource => resource.Parent!.Id == 3); + + var rewriter = new NullSafeExpressionRewriter(); + + // Act + Expression, IEnumerable>> safeExpression = rewriter.Rewrite(expression); + + // Assert + safeExpression.ToString().Should().Be("source => source.Where(resource => ((resource.Parent != null) AndAlso (resource.Parent.Id == 3)))"); + + TestResource[] resources = DynamicInvoke(safeExpression, dataSource); + resources.Should().HaveCount(1); + resources[0].Id.Should().Be(lastInDataSource.Id); + } + + [Fact] + public void Can_rewrite_where_clause_with_member_comparison() + { + // Arrange + var generator = new IdGenerator(); + + var dataSource = new List + { + new() + { + Id = generator.GetNext() + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + } + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + }, + Children = + { + new TestResource + { + Id = generator.GetNext() + } + } + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + }, + Children = + { + new TestResource + { + Id = generator.GetNext(), + Parent = new TestResource() + } + } + } + }; + + TestResource lastInDataSource = dataSource.Last(); + lastInDataSource.FirstChild!.Parent!.Id = lastInDataSource.Parent!.Id; + + Expression, IEnumerable>> expression = source => + source.Where(resource => resource.Parent!.Id == resource.FirstChild!.Parent!.Id); + + var rewriter = new NullSafeExpressionRewriter(); + + // Act + Expression, IEnumerable>> safeExpression = rewriter.Rewrite(expression); + + // Assert + safeExpression.ToString().Should().Be( + "source => source.Where(resource => ((resource.Parent != null) AndAlso ((resource.FirstChild != null) AndAlso ((resource.FirstChild.Parent != null) AndAlso (resource.Parent.Id == resource.FirstChild.Parent.Id)))))"); + + TestResource[] resources = DynamicInvoke(safeExpression, dataSource); + resources.Should().HaveCount(1); + resources[0].Id.Should().Be(lastInDataSource.Id); + } + + [Fact] + public void Can_rewrite_where_clause_with_not_comparison() + { + // Arrange + var generator = new IdGenerator(); + + var dataSource = new List + { + new() + { + Id = generator.GetNext() + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + } + } + }; + + // ReSharper disable once NegativeEqualityExpression + Expression, IEnumerable>> expression = source => source.Where(resource => !(resource.Parent!.Id == 3)); + + var rewriter = new NullSafeExpressionRewriter(); + + // Act + Expression, IEnumerable>> safeExpression = rewriter.Rewrite(expression); + + // Assert + safeExpression.ToString().Should().Be("source => source.Where(resource => Not(((resource.Parent != null) AndAlso (resource.Parent.Id == 3))))"); + + TestResource[] resources = DynamicInvoke(safeExpression, dataSource); + resources.Should().HaveCount(1); + resources[0].Id.Should().Be(dataSource[0].Id); + } + + [Fact] + public void Can_rewrite_where_clause_with_any_comparison() + { + // Arrange + var generator = new IdGenerator(); + + var dataSource = new List + { + new() + { + Id = generator.GetNext() + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + } + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext(), + Children = + { + new TestResource + { + Id = generator.GetNext() + } + } + } + } + } + }; + + TestResource lastInDataSource = dataSource.Last(); + + Expression, IEnumerable>> expression = source => + source.Where(resource => resource.Parent!.Parent!.Children.Any()); + + var rewriter = new NullSafeExpressionRewriter(); + + // Act + Expression, IEnumerable>> safeExpression = rewriter.Rewrite(expression); + + // Assert + safeExpression.ToString().Should().Be( + "source => source.Where(resource => ((resource.Parent != null) AndAlso ((resource.Parent.Parent != null) AndAlso ((resource.Parent.Parent.Children != null) AndAlso resource.Parent.Parent.Children.Any()))))"); + + TestResource[] resources = DynamicInvoke(safeExpression, dataSource); + resources.Should().HaveCount(1); + resources[0].Id.Should().Be(lastInDataSource.Id); + } + + [Fact] + public void Can_rewrite_where_clause_with_conditional_any_comparison() + { + // Arrange + var generator = new IdGenerator(); + + var dataSource = new List + { + new() + { + Id = generator.GetNext() + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + } + }, + new() + { + Id = generator.GetNext(), + Children = + { + new TestResource + { + Id = generator.GetNext(), + Children = + { + new TestResource + { + Id = generator.GetNext() + } + } + } + } + } + }; + + // ReSharper disable once NegativeEqualityExpression + Expression, IEnumerable>> expression = source => source.Where(resource => + resource.Parent!.Id == 3 || resource.FirstChild!.Children.Any(child => !(child.Parent!.Id == 1))); + + var rewriter = new NullSafeExpressionRewriter(); + + // Act + Expression, IEnumerable>> safeExpression = rewriter.Rewrite(expression); + + // Assert + safeExpression.ToString().Should().Be("source => source.Where(resource => (((resource.Parent != null) AndAlso (resource.Parent.Id == 3)) OrElse " + + "((resource.FirstChild != null) AndAlso ((resource.FirstChild.Children != null) AndAlso resource.FirstChild.Children.Any(child => Not(((child.Parent != null) AndAlso (child.Parent.Id == 1))))))))"); + + TestResource[] resources = DynamicInvoke(safeExpression, dataSource); + resources.Should().HaveCount(2); + resources[0].Id.Should().Be(dataSource[1].Id); + resources[1].Id.Should().Be(dataSource[2].Id); + } + + [Fact] + public void Can_rewrite_where_clause_with_nested_conditional_any_comparison() + { + // Arrange + var generator = new IdGenerator(); + + var dataSource = new List + { + new() + { + Id = generator.GetNext(), + Children = null! + }, + new() + { + Id = generator.GetNext(), + Children = + { + new TestResource + { + Id = generator.GetNext(), + Children = + { + new TestResource + { + Id = generator.GetNext() + } + } + } + } + }, + new() + { + Id = generator.GetNext(), + Children = + { + new TestResource + { + Id = generator.GetNext(), + Children = + { + new TestResource + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext(), + Name = "Jack" + } + } + } + } + } + } + }; + + TestResource lastInDataSource = dataSource.Last(); + + // ReSharper disable once NegativeEqualityExpression + Expression, IEnumerable>> expression = source => source.Where(resource => + resource.Children.Any(child => child.Children.Any(childOfChild => childOfChild.Parent!.Name == "Jack"))); + + var rewriter = new NullSafeExpressionRewriter(); + + // Act + Expression, IEnumerable>> safeExpression = rewriter.Rewrite(expression); + + // Assert + safeExpression.ToString().Should().Be("source => source.Where(resource => " + + "((resource.Children != null) AndAlso resource.Children.Any(child => ((child.Children != null) AndAlso child.Children.Any(childOfChild => ((childOfChild.Parent != null) AndAlso (childOfChild.Parent.Name == \"Jack\")))))))"); + + TestResource[] resources = DynamicInvoke(safeExpression, dataSource); + resources.Should().HaveCount(1); + resources[0].Id.Should().Be(lastInDataSource.Id); + } + + [Fact] + public void Can_rewrite_where_clause_with_count_comparison() + { + // Arrange + var generator = new IdGenerator(); + + var dataSource = new List + { + new() + { + Id = generator.GetNext() + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + }, + Children = null! + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext(), + Children = + { + new TestResource + { + Id = generator.GetNext() + } + } + }, + Children = + { + new TestResource + { + Id = generator.GetNext() + }, + new TestResource + { + Id = generator.GetNext() + } + } + } + }; + + TestResource lastInDataSource = dataSource.Last(); + + // ReSharper disable UseCollectionCountProperty + Expression, IEnumerable>> expression = source => + source.Where(resource => resource.Children.Count() > resource.Parent!.Children.Count()); + // ReSharper restore UseCollectionCountProperty + + var rewriter = new NullSafeExpressionRewriter(); + + // Act + Expression, IEnumerable>> safeExpression = rewriter.Rewrite(expression); + + // Assert + safeExpression.ToString().Should().Be( + "source => source.Where(resource => ((resource.Children != null) AndAlso ((resource.Parent != null) AndAlso ((resource.Parent.Children != null) AndAlso (resource.Children.Count() > resource.Parent.Children.Count())))))"); + + TestResource[] resources = DynamicInvoke(safeExpression, dataSource); + resources.Should().HaveCount(1); + resources[0].Id.Should().Be(lastInDataSource.Id); + } + + [Fact] + public void Can_rewrite_order_by_clause_with_long() + { + // Arrange + var generator = new IdGenerator(); + + var dataSource = new List + { + new() + { + Id = generator.GetNext() + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + } + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + } + } + }; + + Expression, IEnumerable>> expression = source => source.OrderBy(resource => resource.Parent!.Id); + + var rewriter = new NullSafeExpressionRewriter(); + + // Act + Expression, IEnumerable>> safeExpression = rewriter.Rewrite(expression); + + // Assert + safeExpression.ToString().Should().Be("source => source.OrderBy(resource => IIF((resource.Parent == null), -9223372036854775808, resource.Parent.Id))"); + + TestResource[] resources = DynamicInvoke(safeExpression, dataSource); + resources.Should().HaveCount(3); + resources[0].Id.Should().Be(dataSource[0].Id); + resources[1].Id.Should().Be(dataSource[1].Id); + resources[2].Id.Should().Be(dataSource[2].Id); + } + + [Fact] + public void Can_rewrite_order_by_clause_with_IntPtr() + { + // Arrange + var generator = new IdGenerator(); + + var dataSource = new List + { + new() + { + Id = generator.GetNext() + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + } + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext(), + Pointer = (IntPtr)1 + } + } + }; + + Expression, IEnumerable>> expression = source => source.OrderBy(resource => resource.Parent!.Pointer); + + var rewriter = new NullSafeExpressionRewriter(); + + // Act + Expression, IEnumerable>> safeExpression = rewriter.Rewrite(expression); + + // Assert + safeExpression.ToString().Should() + .Be("source => source.OrderBy(resource => IIF((resource.Parent == null), -9223372036854775808, resource.Parent.Pointer))"); + + TestResource[] resources = DynamicInvoke(safeExpression, dataSource); + resources.Should().HaveCount(3); + resources[0].Id.Should().Be(dataSource[0].Id); + resources[1].Id.Should().Be(dataSource[1].Id); + resources[2].Id.Should().Be(dataSource[2].Id); + } + + [Fact] + public void Can_rewrite_order_by_clause_with_nullable_int() + { + // Arrange + var generator = new IdGenerator(); + + var dataSource = new List + { + new() + { + Id = generator.GetNext() + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + } + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext(), + Number = -1 + } + } + }; + + Expression, IEnumerable>> expression = source => source.OrderBy(resource => resource.Parent!.Number); + + var rewriter = new NullSafeExpressionRewriter(); + + // Act + Expression, IEnumerable>> safeExpression = rewriter.Rewrite(expression); + + // Assert + safeExpression.ToString().Should().Be("source => source.OrderBy(resource => IIF((resource.Parent == null), null, resource.Parent.Number))"); + + TestResource[] resources = DynamicInvoke(safeExpression, dataSource); + resources.Should().HaveCount(3); + resources[0].Id.Should().Be(dataSource[0].Id); + resources[1].Id.Should().Be(dataSource[1].Id); + resources[2].Id.Should().Be(dataSource[2].Id); + } + + [Fact] + public void Can_rewrite_order_by_clause_with_enum() + { + // Arrange + var generator = new IdGenerator(); + + var dataSource = new List + { + new() + { + Id = generator.GetNext() + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + } + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext(), + Enum = TestEnum.Two + } + } + }; + + Expression, IEnumerable>> expression = source => source.OrderBy(resource => resource.Parent!.Enum); + + var rewriter = new NullSafeExpressionRewriter(); + + // Act + Expression, IEnumerable>> safeExpression = rewriter.Rewrite(expression); + + // Assert + safeExpression.ToString().Should().Be("source => source.OrderBy(resource => IIF((resource.Parent == null), Zero, resource.Parent.Enum))"); + + TestResource[] resources = DynamicInvoke(safeExpression, dataSource); + resources.Should().HaveCount(3); + resources[0].Id.Should().Be(dataSource[0].Id); + resources[1].Id.Should().Be(dataSource[1].Id); + resources[2].Id.Should().Be(dataSource[2].Id); + } + + [Fact] + public void Can_rewrite_order_by_clause_with_string() + { + // Arrange + var generator = new IdGenerator(); + + var dataSource = new List + { + new() + { + Id = generator.GetNext() + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + } + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext(), + Name = "X" + } + } + }; + + Expression, IEnumerable>> expression = source => source.OrderBy(resource => resource.Parent!.Name); + + var rewriter = new NullSafeExpressionRewriter(); + + // Act + Expression, IEnumerable>> safeExpression = rewriter.Rewrite(expression); + + // Assert + safeExpression.ToString().Should().Be("source => source.OrderBy(resource => IIF((resource.Parent == null), null, resource.Parent.Name))"); + + TestResource[] resources = DynamicInvoke(safeExpression, dataSource); + resources.Should().HaveCount(3); + resources[0].Id.Should().Be(dataSource[0].Id); + resources[1].Id.Should().Be(dataSource[1].Id); + resources[2].Id.Should().Be(dataSource[2].Id); + } + + [Fact] + public void Can_rewrite_order_by_clause_with_count() + { + // Arrange + var generator = new IdGenerator(); + + var dataSource = new List + { + new() + { + Id = generator.GetNext() + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + } + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + }, + Children = null! + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + }, + Children = + { + new TestResource + { + Id = generator.GetNext() + } + } + } + }; + + // ReSharper disable once UseCollectionCountProperty + Expression, IEnumerable>> expression = source => + source.OrderBy(resource => resource.Parent!.Children.Count()); + + var rewriter = new NullSafeExpressionRewriter(); + + // Act + Expression, IEnumerable>> safeExpression = rewriter.Rewrite(expression); + + // Assert + safeExpression.ToString().Should().Be( + "source => source.OrderBy(resource => IIF((resource.Parent == null), -2147483648, IIF((resource.Parent.Children == null), -2147483648, resource.Parent.Children.Count())))"); + + TestResource[] resources = DynamicInvoke(safeExpression, dataSource); + resources.Should().HaveCount(4); + resources[0].Id.Should().Be(dataSource[0].Id); + resources[1].Id.Should().Be(dataSource[1].Id); + resources[2].Id.Should().Be(dataSource[2].Id); + resources[3].Id.Should().Be(dataSource[3].Id); + } + + [Fact] + public void Can_rewrite_nested_descending_order_by_clauses() + { + // Arrange + var generator = new IdGenerator(); + + var dataSource = new List + { + new() + { + Id = generator.GetNext() + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext(), + Name = "A", + Number = 1 + } + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext(), + Name = "A", + Number = 10 + } + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext(), + Name = "Z", + Number = 1 + } + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext(), + Name = "Z", + Number = 10 + } + } + }; + + Expression, IEnumerable>> expression = source => + source.OrderByDescending(resource => resource.Parent!.Name).ThenByDescending(resource => resource.Parent!.Number); + + var rewriter = new NullSafeExpressionRewriter(); + + // Act + Expression, IEnumerable>> safeExpression = rewriter.Rewrite(expression); + + // Assert + safeExpression.ToString().Should().Be( + "source => source.OrderByDescending(resource => IIF((resource.Parent == null), null, resource.Parent.Name)).ThenByDescending(resource => IIF((resource.Parent == null), null, resource.Parent.Number))"); + + TestResource[] resources = DynamicInvoke(safeExpression, dataSource); + resources.Should().HaveCount(5); + resources[0].Id.Should().Be(dataSource[4].Id); + resources[1].Id.Should().Be(dataSource[3].Id); + resources[2].Id.Should().Be(dataSource[2].Id); + resources[3].Id.Should().Be(dataSource[1].Id); + resources[4].Id.Should().Be(dataSource[0].Id); + } + + [Fact] + public void Does_not_rewrite_in_select() + { + // Arrange + Expression, IEnumerable>> expression = source => source.Select(resource => new TestResource + { + Id = resource.Id, + Name = resource.Name, + Parent = resource.Parent, + Children = resource.Children + }); + + var rewriter = new NullSafeExpressionRewriter(); + + // Act + Expression, IEnumerable>> safeExpression = rewriter.Rewrite(expression); + + // Assert + safeExpression.ToString().Should().Be(expression.ToString()); + } + + private static TestResource[] DynamicInvoke(Expression, IEnumerable>> expression, + IEnumerable dataSource) + { + Delegate function = expression.Compile(); + object enumerable = function.DynamicInvoke(dataSource)!; + return ((IEnumerable)enumerable).ToArray(); + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + private sealed class TestResource : Identifiable + { + [Attr] + public string? Name { get; set; } + + [Attr] + public int? Number { get; set; } + + [Attr] + public IntPtr Pointer { get; set; } + + [Attr] + public TestEnum Enum { get; set; } + + [HasOne] + public TestResource? Parent { get; set; } + + [HasOne] + public TestResource? FirstChild => Children.FirstOrDefault(); + + [HasMany] + public ISet Children { get; set; } = new HashSet(); + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + private enum TestEnum + { + Zero, + One, + Two + } + + private sealed class IdGenerator + { + private long _lastId; + + public long GetNext() + { + return ++_lastId; + } + } +} diff --git a/test/NoEntityFrameworkTests/PersonTests.cs b/test/NoEntityFrameworkTests/PersonTests.cs new file mode 100644 index 0000000000..965cac9a3e --- /dev/null +++ b/test/NoEntityFrameworkTests/PersonTests.cs @@ -0,0 +1,225 @@ +using System.Net; +using System.Text.Json; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using NoEntityFrameworkExample.Models; +using TestBuildingBlocks; +using Xunit; + +namespace NoEntityFrameworkTests; + +public sealed class PersonTests : IntegrationTest, IClassFixture> +{ + private readonly NoLoggingWebApplicationFactory _factory; + + protected override JsonSerializerOptions SerializerOptions + { + get + { + var options = _factory.Services.GetRequiredService(); + return options.SerializerOptions; + } + } + + public PersonTests(NoLoggingWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task Can_get_primary_resources() + { + // Arrange + const string route = "/api/people"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(2); + + responseDocument.Meta.Should().ContainTotal(2); + } + + [Fact] + public async Task Can_filter_in_primary_resources() + { + // Arrange + const string route = "/api/people?filter=equals(firstName,'Jane')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("firstName").With(value => value.Should().Be("Jane")); + + responseDocument.Meta.Should().ContainTotal(1); + } + + [Fact] + public async Task Can_filter_in_related_resources() + { + // Arrange + const string route = "/api/people?filter=has(assignedTodoItems,equals(description,'Check emails'))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("firstName").With(value => value.Should().Be("John")); + + responseDocument.Meta.Should().ContainTotal(1); + } + + [Fact] + public async Task Can_sort_on_attribute_in_primary_resources() + { + // Arrange + const string route = "/api/people?sort=-id"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(2); + responseDocument.Data.ManyValue[0].Id.Should().Be("2"); + responseDocument.Data.ManyValue[1].Id.Should().Be("1"); + } + + [Fact] + public async Task Can_sort_on_count_in_primary_resources() + { + // Arrange + const string route = "/api/people?sort=-count(assignedTodoItems)"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(2); + responseDocument.Data.ManyValue[0].Id.Should().Be("1"); + responseDocument.Data.ManyValue[1].Id.Should().Be("2"); + } + + [Fact] + public async Task Can_paginate_in_primary_resources() + { + // Arrange + const string route = "/api/people?page[size]=1&page[number]=2&sort=id"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("firstName").With(value => value.Should().Be("Jane")); + + responseDocument.Meta.Should().ContainTotal(2); + } + + [Fact] + public async Task Can_select_fields_in_primary_resources() + { + // Arrange + const string route = "/api/people?fields[people]=lastName,displayName"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldNotBeEmpty(); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Attributes.ShouldOnlyContainKeys("lastName", "displayName")); + } + + [Fact] + public async Task Can_include_in_primary_resources() + { + // Arrange + const string route = "/api/people?include=ownedTodoItems.assignee,assignedTodoItems"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().NotBeEmpty(); + responseDocument.Included.Should().NotBeEmpty(); + } + + [Fact] + public async Task Can_get_primary_resource() + { + // Arrange + const string route = "/api/people/1"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Id.Should().Be("1"); + } + + [Fact] + public async Task Can_get_secondary_resources() + { + // Arrange + const string route = "/api/people/1/ownedTodoItems?sort=id"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(2); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("description").With(value => value.Should().Be("Make homework")); + responseDocument.Data.ManyValue[1].Attributes.ShouldContainKey("description").With(value => value.Should().Be("Check emails")); + + responseDocument.Meta.Should().ContainTotal(2); + } + + [Fact] + public async Task Can_get_ToMany_relationship() + { + // Arrange + const string route = "/api/people/2/relationships/assignedTodoItems"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be("1"); + + responseDocument.Meta.Should().ContainTotal(1); + } + + protected override HttpClient CreateClient() + { + return _factory.CreateClient(); + } +} diff --git a/test/NoEntityFrameworkTests/TodoItemTests.cs b/test/NoEntityFrameworkTests/TodoItemTests.cs new file mode 100644 index 0000000000..3fdd5683c5 --- /dev/null +++ b/test/NoEntityFrameworkTests/TodoItemTests.cs @@ -0,0 +1,349 @@ +using System.Net; +using System.Text.Json; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using NoEntityFrameworkExample.Models; +using TestBuildingBlocks; +using Xunit; + +namespace NoEntityFrameworkTests; + +public sealed class TodoItemTests : IntegrationTest, IClassFixture> +{ + private readonly NoLoggingWebApplicationFactory _factory; + + protected override JsonSerializerOptions SerializerOptions + { + get + { + var options = _factory.Services.GetRequiredService(); + return options.SerializerOptions; + } + } + + public TodoItemTests(NoLoggingWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task Can_get_primary_resources() + { + // Arrange + const string route = "/api/todoItems"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(4); + + responseDocument.Meta.Should().ContainTotal(4); + } + + [Fact] + public async Task Can_filter_in_primary_resources() + { + // Arrange + const string route = "/api/todoItems?filter=equals(priority,'High')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("priority").With(value => value.Should().Be(TodoItemPriority.High)); + + responseDocument.Meta.Should().ContainTotal(1); + } + + [Fact] + public async Task Can_filter_in_related_resources() + { + // Arrange + const string route = "/api/todoItems?filter=not(equals(assignee.firstName,'Jane'))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(3); + + responseDocument.Meta.Should().ContainTotal(3); + } + + [Fact] + public async Task Can_sort_on_attribute_in_primary_resources() + { + // Arrange + const string route = "/api/todoItems?sort=-id"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(4); + responseDocument.Data.ManyValue[0].Id.Should().Be("4"); + responseDocument.Data.ManyValue[1].Id.Should().Be("3"); + responseDocument.Data.ManyValue[2].Id.Should().Be("2"); + responseDocument.Data.ManyValue[3].Id.Should().Be("1"); + } + + [Fact] + public async Task Can_sort_on_count_in_primary_resources() + { + // Arrange + const string route = "/api/todoItems?sort=count(assignee.ownedTodoItems)"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(4); + responseDocument.Data.ManyValue[0].Id.Should().Be("2"); + } + + [Fact] + public async Task Can_paginate_in_primary_resources() + { + // Arrange + const string route = "/api/todoItems?page[size]=3&page[number]=2&sort=id"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("description").With(value => value.Should().Be("Check emails")); + + responseDocument.Meta.Should().ContainTotal(4); + } + + [Fact] + public async Task Can_select_fields_in_primary_resources() + { + // Arrange + const string route = "/api/todoItems?fields[todoItems]=description,priority"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldNotBeEmpty(); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Attributes.ShouldOnlyContainKeys("description", "priority")); + } + + [Fact] + public async Task Can_include_in_primary_resources() + { + // Arrange + const string route = "/api/todoItems?include=owner.assignedTodoItems,assignee,tags"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().NotBeEmpty(); + responseDocument.Included.Should().NotBeEmpty(); + } + + [Fact] + public async Task Can_get_primary_resource() + { + // Arrange + const string route = "/api/todoItems/1"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Id.Should().Be("1"); + } + + [Fact] + public async Task Cannot_get_primary_resource_for_unknown_ID() + { + // Arrange + const string route = "/api/todoItems/999999"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be("Resource of type 'todoItems' with ID '999999' does not exist."); + } + + [Fact] + public async Task Can_get_secondary_resources() + { + // Arrange + const string route = "/api/todoItems/3/tags?sort=id"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(2); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("name").With(value => value.Should().Be("Personal")); + responseDocument.Data.ManyValue[1].Attributes.ShouldContainKey("name").With(value => value.Should().Be("Family")); + + responseDocument.Meta.Should().ContainTotal(2); + } + + [Fact] + public async Task Can_get_secondary_resource() + { + // Arrange + const string route = "/api/todoItems/2/owner"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("people"); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("firstName").With(value => value.Should().Be("Jane")); + } + + [Fact] + public async Task Cannot_get_secondary_resource_for_unknown_primary_ID() + { + // Arrange + const string route = "/api/todoItems/999999/owner"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be("Resource of type 'todoItems' with ID '999999' does not exist."); + } + + [Fact] + public async Task Can_get_secondary_resource_for_unknown_secondary_ID() + { + // Arrange + const string route = "/api/todoItems/2/assignee"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.Should().BeNull(); + } + + [Fact] + public async Task Cannot_get_secondary_resource_for_unknown_relationship() + { + // Arrange + const string route = "/api/todoItems/2/unknown"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested relationship does not exist."); + error.Detail.Should().Be("Resource of type 'todoItems' does not contain a relationship named 'unknown'."); + } + + [Fact] + public async Task Can_get_ToOne_relationship() + { + // Arrange + const string route = "/api/todoItems/2/relationships/owner"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("people"); + responseDocument.Data.SingleValue.Id.Should().Be("2"); + } + + [Fact] + public async Task Can_get_empty_ToOne_relationship() + { + // Arrange + const string route = "/api/todoItems/2/relationships/assignee"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.Should().BeNull(); + } + + [Fact] + public async Task Can_get_ToMany_relationship() + { + // Arrange + const string route = "/api/todoItems/4/relationships/tags?sort=id"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be("3"); + + responseDocument.Meta.Should().ContainTotal(1); + } + + protected override HttpClient CreateClient() + { + return _factory.CreateClient(); + } +} diff --git a/test/NoEntityFrameworkTests/WorkItemTests.cs b/test/NoEntityFrameworkTests/WorkItemTests.cs deleted file mode 100644 index 623f05ed1d..0000000000 --- a/test/NoEntityFrameworkTests/WorkItemTests.cs +++ /dev/null @@ -1,167 +0,0 @@ -using System.Net; -using System.Text.Json; -using FluentAssertions; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.Extensions.DependencyInjection; -using NoEntityFrameworkExample.Data; -using NoEntityFrameworkExample.Models; -using TestBuildingBlocks; -using Xunit; - -namespace NoEntityFrameworkTests; - -public sealed class WorkItemTests : IntegrationTest, IClassFixture> -{ - private readonly WebApplicationFactory _factory; - - protected override JsonSerializerOptions SerializerOptions - { - get - { - var options = _factory.Services.GetRequiredService(); - return options.SerializerOptions; - } - } - - public WorkItemTests(WebApplicationFactory factory) - { - _factory = factory; - } - - [Fact] - public async Task Can_get_WorkItems() - { - var workItem = new WorkItem - { - Title = "Write some code." - }; - - // Arrange - await RunOnDatabaseAsync(async dbContext => - { - dbContext.WorkItems.Add(workItem); - await dbContext.SaveChangesAsync(); - }); - - const string route = "/api/v1/workItems"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Data.ManyValue.ShouldNotBeEmpty(); - } - - [Fact] - public async Task Can_get_WorkItem_by_ID() - { - // Arrange - var workItem = new WorkItem - { - Title = "Write some code." - }; - - await RunOnDatabaseAsync(async dbContext => - { - dbContext.WorkItems.Add(workItem); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/api/v1/workItems/{workItem.StringId}"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Id.Should().Be(workItem.StringId); - } - - [Fact] - public async Task Can_create_WorkItem() - { - // Arrange - var newWorkItem = new WorkItem - { - IsBlocked = true, - Title = "Some", - DurationInHours = 2, - ProjectId = Guid.NewGuid() - }; - - var requestBody = new - { - data = new - { - type = "workItems", - attributes = new - { - isBlocked = newWorkItem.IsBlocked, - title = newWorkItem.Title, - durationInHours = newWorkItem.DurationInHours, - projectId = newWorkItem.ProjectId - } - } - }; - - const string route = "/api/v1/workItems/"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); - - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Attributes.ShouldNotBeEmpty(); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("isBlocked").With(value => value.Should().Be(newWorkItem.IsBlocked)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newWorkItem.Title)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("durationInHours").With(value => value.Should().Be(newWorkItem.DurationInHours)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("projectId").With(value => value.Should().Be(newWorkItem.ProjectId)); - } - - [Fact] - public async Task Can_delete_WorkItem() - { - // Arrange - var workItem = new WorkItem - { - Title = "Write some code." - }; - - await RunOnDatabaseAsync(async dbContext => - { - dbContext.WorkItems.Add(workItem); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/api/v1/workItems/{workItem.StringId}"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await ExecuteDeleteAsync(route); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - } - - protected override HttpClient CreateClient() - { - return _factory.CreateClient(); - } - - private async Task RunOnDatabaseAsync(Func asyncAction) - { - await using AsyncServiceScope scope = _factory.Services.CreateAsyncScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - - await asyncAction(dbContext); - } -} diff --git a/test/OpenApiClientTests/LegacyClient/RequestTests.cs b/test/OpenApiClientTests/LegacyClient/RequestTests.cs index e03e8f1015..dfdc2ce2a7 100644 --- a/test/OpenApiClientTests/LegacyClient/RequestTests.cs +++ b/test/OpenApiClientTests/LegacyClient/RequestTests.cs @@ -12,7 +12,7 @@ namespace OpenApiClientTests.LegacyClient; public sealed class RequestTests { - private const string HostPrefix = "http://localhost/api/v1/"; + private const string HostPrefix = "http://localhost/api/"; [Fact] public async Task Getting_resource_collection_produces_expected_request() diff --git a/test/OpenApiClientTests/LegacyClient/ResponseTests.cs b/test/OpenApiClientTests/LegacyClient/ResponseTests.cs index 10f86adca5..76df03bb32 100644 --- a/test/OpenApiClientTests/LegacyClient/ResponseTests.cs +++ b/test/OpenApiClientTests/LegacyClient/ResponseTests.cs @@ -10,7 +10,7 @@ namespace OpenApiClientTests.LegacyClient; public sealed class ResponseTests { - private const string HostPrefix = "http://localhost/api/v1/"; + private const string HostPrefix = "http://localhost/api/"; [Fact] public async Task Getting_resource_collection_translates_response() diff --git a/test/OpenApiClientTests/LegacyClient/swagger.g.json b/test/OpenApiClientTests/LegacyClient/swagger.g.json index f95214e8d4..d408c3589c 100644 --- a/test/OpenApiClientTests/LegacyClient/swagger.g.json +++ b/test/OpenApiClientTests/LegacyClient/swagger.g.json @@ -5,7 +5,7 @@ "version": "1.0" }, "paths": { - "/api/v1/airplanes": { + "/api/airplanes": { "get": { "tags": [ "airplanes" @@ -73,7 +73,7 @@ } } }, - "/api/v1/airplanes/{id}": { + "/api/airplanes/{id}": { "get": { "tags": [ "airplanes" @@ -192,7 +192,7 @@ } } }, - "/api/v1/airplanes/{id}/flights": { + "/api/airplanes/{id}/flights": { "get": { "tags": [ "airplanes" @@ -250,7 +250,7 @@ } } }, - "/api/v1/airplanes/{id}/relationships/flights": { + "/api/airplanes/{id}/relationships/flights": { "get": { "tags": [ "airplanes" @@ -398,7 +398,7 @@ } } }, - "/api/v1/flight-attendants": { + "/api/flight-attendants": { "get": { "tags": [ "flight-attendants" @@ -466,7 +466,7 @@ } } }, - "/api/v1/flight-attendants/{id}": { + "/api/flight-attendants/{id}": { "get": { "tags": [ "flight-attendants" @@ -585,7 +585,7 @@ } } }, - "/api/v1/flight-attendants/{id}/purser-on-flights": { + "/api/flight-attendants/{id}/purser-on-flights": { "get": { "tags": [ "flight-attendants" @@ -643,7 +643,7 @@ } } }, - "/api/v1/flight-attendants/{id}/relationships/purser-on-flights": { + "/api/flight-attendants/{id}/relationships/purser-on-flights": { "get": { "tags": [ "flight-attendants" @@ -791,7 +791,7 @@ } } }, - "/api/v1/flight-attendants/{id}/scheduled-for-flights": { + "/api/flight-attendants/{id}/scheduled-for-flights": { "get": { "tags": [ "flight-attendants" @@ -849,7 +849,7 @@ } } }, - "/api/v1/flight-attendants/{id}/relationships/scheduled-for-flights": { + "/api/flight-attendants/{id}/relationships/scheduled-for-flights": { "get": { "tags": [ "flight-attendants" @@ -997,7 +997,7 @@ } } }, - "/api/v1/flights": { + "/api/flights": { "get": { "tags": [ "flights" @@ -1065,7 +1065,7 @@ } } }, - "/api/v1/flights/{id}": { + "/api/flights/{id}": { "get": { "tags": [ "flights" @@ -1184,7 +1184,7 @@ } } }, - "/api/v1/flights/{id}/backup-purser": { + "/api/flights/{id}/backup-purser": { "get": { "tags": [ "flights" @@ -1242,7 +1242,7 @@ } } }, - "/api/v1/flights/{id}/relationships/backup-purser": { + "/api/flights/{id}/relationships/backup-purser": { "get": { "tags": [ "flights" @@ -1330,7 +1330,7 @@ } } }, - "/api/v1/flights/{id}/cabin-crew-members": { + "/api/flights/{id}/cabin-crew-members": { "get": { "tags": [ "flights" @@ -1388,7 +1388,7 @@ } } }, - "/api/v1/flights/{id}/relationships/cabin-crew-members": { + "/api/flights/{id}/relationships/cabin-crew-members": { "get": { "tags": [ "flights" @@ -1536,7 +1536,7 @@ } } }, - "/api/v1/flights/{id}/passengers": { + "/api/flights/{id}/passengers": { "get": { "tags": [ "flights" @@ -1594,7 +1594,7 @@ } } }, - "/api/v1/flights/{id}/relationships/passengers": { + "/api/flights/{id}/relationships/passengers": { "get": { "tags": [ "flights" @@ -1742,7 +1742,7 @@ } } }, - "/api/v1/flights/{id}/purser": { + "/api/flights/{id}/purser": { "get": { "tags": [ "flights" @@ -1800,7 +1800,7 @@ } } }, - "/api/v1/flights/{id}/relationships/purser": { + "/api/flights/{id}/relationships/purser": { "get": { "tags": [ "flights" diff --git a/test/OpenApiTests/LegacyOpenApiIntegration/LegacyOpenApiIntegrationStartup.cs b/test/OpenApiTests/LegacyOpenApiIntegration/LegacyOpenApiIntegrationStartup.cs index 2cf6da28ba..38cd66b41d 100644 --- a/test/OpenApiTests/LegacyOpenApiIntegration/LegacyOpenApiIntegrationStartup.cs +++ b/test/OpenApiTests/LegacyOpenApiIntegration/LegacyOpenApiIntegrationStartup.cs @@ -14,7 +14,7 @@ protected override void SetJsonApiOptions(JsonApiOptions options) { base.SetJsonApiOptions(options); - options.Namespace = "api/v1"; + options.Namespace = "api"; options.DefaultAttrCapabilities = AttrCapabilities.AllowView; options.SerializerOptions.PropertyNamingPolicy = JsonKebabCaseNamingPolicy.Instance; options.SerializerOptions.DictionaryKeyPolicy = JsonKebabCaseNamingPolicy.Instance; diff --git a/test/OpenApiTests/LegacyOpenApiIntegration/swagger.json b/test/OpenApiTests/LegacyOpenApiIntegration/swagger.json index 4b6c140fc4..ef84909096 100644 --- a/test/OpenApiTests/LegacyOpenApiIntegration/swagger.json +++ b/test/OpenApiTests/LegacyOpenApiIntegration/swagger.json @@ -5,7 +5,7 @@ "version": "1.0" }, "paths": { - "/api/v1/airplanes": { + "/api/airplanes": { "get": { "tags": [ "airplanes" @@ -73,7 +73,7 @@ } } }, - "/api/v1/airplanes/{id}": { + "/api/airplanes/{id}": { "get": { "tags": [ "airplanes" @@ -192,7 +192,7 @@ } } }, - "/api/v1/airplanes/{id}/flights": { + "/api/airplanes/{id}/flights": { "get": { "tags": [ "airplanes" @@ -250,7 +250,7 @@ } } }, - "/api/v1/airplanes/{id}/relationships/flights": { + "/api/airplanes/{id}/relationships/flights": { "get": { "tags": [ "airplanes" @@ -398,7 +398,7 @@ } } }, - "/api/v1/flight-attendants": { + "/api/flight-attendants": { "get": { "tags": [ "flight-attendants" @@ -466,7 +466,7 @@ } } }, - "/api/v1/flight-attendants/{id}": { + "/api/flight-attendants/{id}": { "get": { "tags": [ "flight-attendants" @@ -585,7 +585,7 @@ } } }, - "/api/v1/flight-attendants/{id}/purser-on-flights": { + "/api/flight-attendants/{id}/purser-on-flights": { "get": { "tags": [ "flight-attendants" @@ -643,7 +643,7 @@ } } }, - "/api/v1/flight-attendants/{id}/relationships/purser-on-flights": { + "/api/flight-attendants/{id}/relationships/purser-on-flights": { "get": { "tags": [ "flight-attendants" @@ -791,7 +791,7 @@ } } }, - "/api/v1/flight-attendants/{id}/scheduled-for-flights": { + "/api/flight-attendants/{id}/scheduled-for-flights": { "get": { "tags": [ "flight-attendants" @@ -849,7 +849,7 @@ } } }, - "/api/v1/flight-attendants/{id}/relationships/scheduled-for-flights": { + "/api/flight-attendants/{id}/relationships/scheduled-for-flights": { "get": { "tags": [ "flight-attendants" @@ -997,7 +997,7 @@ } } }, - "/api/v1/flights": { + "/api/flights": { "get": { "tags": [ "flights" @@ -1065,7 +1065,7 @@ } } }, - "/api/v1/flights/{id}": { + "/api/flights/{id}": { "get": { "tags": [ "flights" @@ -1184,7 +1184,7 @@ } } }, - "/api/v1/flights/{id}/backup-purser": { + "/api/flights/{id}/backup-purser": { "get": { "tags": [ "flights" @@ -1242,7 +1242,7 @@ } } }, - "/api/v1/flights/{id}/relationships/backup-purser": { + "/api/flights/{id}/relationships/backup-purser": { "get": { "tags": [ "flights" @@ -1330,7 +1330,7 @@ } } }, - "/api/v1/flights/{id}/cabin-crew-members": { + "/api/flights/{id}/cabin-crew-members": { "get": { "tags": [ "flights" @@ -1388,7 +1388,7 @@ } } }, - "/api/v1/flights/{id}/relationships/cabin-crew-members": { + "/api/flights/{id}/relationships/cabin-crew-members": { "get": { "tags": [ "flights" @@ -1536,7 +1536,7 @@ } } }, - "/api/v1/flights/{id}/passengers": { + "/api/flights/{id}/passengers": { "get": { "tags": [ "flights" @@ -1594,7 +1594,7 @@ } } }, - "/api/v1/flights/{id}/relationships/passengers": { + "/api/flights/{id}/relationships/passengers": { "get": { "tags": [ "flights" @@ -1742,7 +1742,7 @@ } } }, - "/api/v1/flights/{id}/purser": { + "/api/flights/{id}/purser": { "get": { "tags": [ "flights" @@ -1800,7 +1800,7 @@ } } }, - "/api/v1/flights/{id}/relationships/purser": { + "/api/flights/{id}/relationships/purser": { "get": { "tags": [ "flights" diff --git a/test/SourceGeneratorTests/ControllerGenerationTests.cs b/test/SourceGeneratorTests/ControllerGenerationTests.cs index 9f5f9f83d3..629c5d49b8 100644 --- a/test/SourceGeneratorTests/ControllerGenerationTests.cs +++ b/test/SourceGeneratorTests/ControllerGenerationTests.cs @@ -25,12 +25,12 @@ public void Can_generate_for_default_controller() .WithNamespaceImportFor(typeof(ResourceAttribute)) .InNamespace("ExampleApi.Models") .WithCode(@" - [Resource] - public sealed class Item : Identifiable - { - [Attr] - public int Value { get; set; } - }") + [Resource] + public sealed class Item : Identifiable + { + [Attr] + public int Value { get; set; } + }") .Build(); Compilation inputCompilation = new CompilationBuilder() @@ -87,12 +87,12 @@ public void Can_generate_for_read_only_controller() .WithNamespaceImportFor(typeof(JsonApiEndpoints)) .InNamespace("ExampleApi.Models") .WithCode(@" - [Resource(GenerateControllerEndpoints = JsonApiEndpoints.Query)] - public sealed class Item : Identifiable - { - [Attr] - public int Value { get; set; } - }") + [Resource(GenerateControllerEndpoints = JsonApiEndpoints.Query)] + public sealed class Item : Identifiable + { + [Attr] + public int Value { get; set; } + }") .Build(); Compilation inputCompilation = new CompilationBuilder() @@ -149,12 +149,12 @@ public void Can_generate_for_write_only_controller() .WithNamespaceImportFor(typeof(JsonApiEndpoints)) .InNamespace("ExampleApi.Models") .WithCode(@" - [Resource(GenerateControllerEndpoints = JsonApiEndpoints.Command)] - public sealed class Item : Identifiable - { - [Attr] - public int Value { get; set; } - }") + [Resource(GenerateControllerEndpoints = JsonApiEndpoints.Command)] + public sealed class Item : Identifiable + { + [Attr] + public int Value { get; set; } + }") .Build(); Compilation inputCompilation = new CompilationBuilder() @@ -211,15 +211,15 @@ public void Can_generate_for_mixed_controller() .WithNamespaceImportFor(typeof(JsonApiEndpoints)) .InNamespace("ExampleApi.Models") .WithCode(@" - [Resource(GenerateControllerEndpoints = NoRelationshipEndpoints)] - public sealed class Item : Identifiable - { - private const JsonApiEndpoints NoRelationshipEndpoints = JsonApiEndpoints.GetCollection | - JsonApiEndpoints.GetSingle | JsonApiEndpoints.Post | JsonApiEndpoints.Patch | JsonApiEndpoints.Delete; - - [Attr] - public int Value { get; set; } - }") + [Resource(GenerateControllerEndpoints = NoRelationshipEndpoints)] + public sealed class Item : Identifiable + { + private const JsonApiEndpoints NoRelationshipEndpoints = JsonApiEndpoints.GetCollection | + JsonApiEndpoints.GetSingle | JsonApiEndpoints.Post | JsonApiEndpoints.Patch | JsonApiEndpoints.Delete; + + [Attr] + public int Value { get; set; } + }") .Build(); Compilation inputCompilation = new CompilationBuilder() @@ -284,11 +284,11 @@ public void Skips_for_resource_without_ResourceAttribute() .WithNamespaceImportFor(typeof(AttrAttribute)) .InNamespace("ExampleApi.Models") .WithCode(@" - public sealed class Item : Identifiable - { - [Attr] - public int Value { get; set; } - }") + public sealed class Item : Identifiable + { + [Attr] + public int Value { get; set; } + }") .Build(); Compilation inputCompilation = new CompilationBuilder() @@ -326,12 +326,12 @@ public void Skips_for_resource_with_no_endpoints() .WithNamespaceImportFor(typeof(JsonApiEndpoints)) .InNamespace("ExampleApi.Models") .WithCode(@" - [Resource(GenerateControllerEndpoints = JsonApiEndpoints.None)] - public sealed class Item : Identifiable - { - [Attr] - public int Value { get; set; } - }") + [Resource(GenerateControllerEndpoints = JsonApiEndpoints.None)] + public sealed class Item : Identifiable + { + [Attr] + public int Value { get; set; } + }") .Build(); Compilation inputCompilation = new CompilationBuilder() @@ -366,24 +366,24 @@ public void Skips_for_missing_dependency_on_JsonApiDotNetCore() string source = new SourceCodeBuilder() .InNamespace("ExampleApi.Models") .WithCode(@" - public abstract class Identifiable - { - } - - public sealed class ResourceAttribute : System.Attribute - { - } - - public sealed class AttrAttribute : System.Attribute - { - } - - [Resource] - public sealed class Item : Identifiable - { - [Attr] - public int Value { get; set; } - }") + public abstract class Identifiable + { + } + + public sealed class ResourceAttribute : System.Attribute + { + } + + public sealed class AttrAttribute : System.Attribute + { + } + + [Resource] + public sealed class Item : Identifiable + { + [Attr] + public int Value { get; set; } + }") .Build(); Compilation inputCompilation = new CompilationBuilder() @@ -422,12 +422,12 @@ public void Skips_for_missing_dependency_on_LoggerFactory() .WithNamespaceImportFor(typeof(ResourceAttribute)) .InNamespace("ExampleApi.Models") .WithCode(@" - [Resource] - public sealed class Item : Identifiable - { - [Attr] - public int Value { get; set; } - }") + [Resource] + public sealed class Item : Identifiable + { + [Attr] + public int Value { get; set; } + }") .Build(); Compilation inputCompilation = new CompilationBuilder() @@ -465,12 +465,12 @@ public void Warns_for_resource_that_does_not_implement_IIdentifiable() .WithNamespaceImportFor(typeof(ResourceAttribute)) .InNamespace("ExampleApi.Models") .WithCode(@" - [Resource] - public sealed class Item - { - [Attr] - public int Value { get; set; } - }") + [Resource] + public sealed class Item + { + [Attr] + public int Value { get; set; } + }") .Build(); Compilation inputCompilation = new CompilationBuilder() @@ -490,9 +490,8 @@ public sealed class Item GeneratorDriverRunResult runResult = driver.GetRunResult(); - runResult.Should() - .HaveSingleDiagnostic( - "(6,21): warning JADNC001: Type 'Item' must implement IIdentifiable when using ResourceAttribute to auto-generate ASP.NET controllers"); + runResult.Should().HaveSingleDiagnostic( + "(6,17): warning JADNC001: Type 'Item' must implement IIdentifiable when using ResourceAttribute to auto-generate ASP.NET controllers"); runResult.Should().NotHaveProducedSourceCode(); } @@ -511,14 +510,14 @@ public void Adds_nullable_enable_for_nullable_reference_ID_type() .WithNamespaceImportFor(typeof(ResourceAttribute)) .InNamespace("ExampleApi.Models") .WithCode(@" - #nullable enable - - [Resource] - public sealed class Item : Identifiable - { - [Attr] - public int Value { get; set; } - }") + #nullable enable + + [Resource] + public sealed class Item : Identifiable + { + [Attr] + public int Value { get; set; } + }") .Build(); Compilation inputCompilation = new CompilationBuilder() @@ -556,12 +555,12 @@ public void Can_generate_for_custom_namespace() .WithNamespaceImportFor(typeof(ResourceAttribute)) .InNamespace("ExampleApi.Models") .WithCode(@" - [Resource(ControllerNamespace = ""Some.Path.To.Generate.Code.In"")] - public sealed class Item : Identifiable - { - [Attr] - public int Value { get; set; } - }") + [Resource(ControllerNamespace = ""Some.Path.To.Generate.Code.In"")] + public sealed class Item : Identifiable + { + [Attr] + public int Value { get; set; } + }") .Build(); Compilation inputCompilation = new CompilationBuilder() @@ -617,12 +616,12 @@ public void Can_generate_for_top_level_namespace() .WithNamespaceImportFor(typeof(ResourceAttribute)) .InNamespace("TopLevel") .WithCode(@" - [Resource] - public sealed class Item : Identifiable - { - [Attr] - public int Value { get; set; } - }") + [Resource] + public sealed class Item : Identifiable + { + [Attr] + public int Value { get; set; } + }") .Build(); Compilation inputCompilation = new CompilationBuilder() @@ -677,12 +676,70 @@ public void Can_generate_for_global_namespace() .WithNamespaceImportFor(typeof(IIdentifiable)) .WithNamespaceImportFor(typeof(ResourceAttribute)) .WithCode(@" - [Resource] - public sealed class Item : Identifiable - { - [Attr] - public int Value { get; set; } - }") + [Resource] + public sealed class Item : Identifiable + { + [Attr] + public int Value { get; set; } + }") + .Build(); + + Compilation inputCompilation = new CompilationBuilder() + .WithDefaultReferences() + .WithSourceCode(source) + .Build(); + + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore + + // Act + driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out Compilation outputCompilation, out _); + + // Assert + inputCompilation.GetDiagnostics().Should().BeEmpty(); + outputCompilation.GetDiagnostics().Should().BeEmpty(); + + GeneratorDriverRunResult runResult = driver.GetRunResult(); + runResult.Should().NotHaveDiagnostics(); + + runResult.Should().HaveProducedSourceCode(@"// + +using Microsoft.Extensions.Logging; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; + +public sealed partial class ItemsController : JsonApiController +{ + public ItemsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) + { + } +} +"); + } + + [Fact] + public void Can_generate_for_shared_namespace() + { + // Arrange + GeneratorDriver driver = CSharpGeneratorDriver.Create(new ControllerSourceGenerator()); + + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true + + string source = new SourceCodeBuilder() + .WithNamespaceImportFor(typeof(IIdentifiable)) + .WithNamespaceImportFor(typeof(ResourceAttribute)) + .InNamespace("ExampleApi") + .WithCode(@" + [Resource(ControllerNamespace = ""ExampleApi"")] + public sealed class Item : Identifiable + { + [Attr] + public int Value { get; set; } + }") .Build(); Compilation inputCompilation = new CompilationBuilder() @@ -710,6 +767,8 @@ public sealed class Item : Identifiable using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Services; +namespace ExampleApi; + public sealed partial class ItemsController : JsonApiController { public ItemsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, @@ -734,25 +793,25 @@ public void Generates_unique_file_names_for_duplicate_resource_name_in_different .WithNamespaceImportFor(typeof(IIdentifiable)) .WithNamespaceImportFor(typeof(ResourceAttribute)) .WithCode(@" - namespace The.First.One + namespace The.First.One + { + [Resource] + public sealed class Item : Identifiable { - [Resource] - public sealed class Item : Identifiable - { - [Attr] - public int Value { get; set; } - } + [Attr] + public int Value { get; set; } } + } - namespace The.Second.One + namespace The.Second.One + { + [Resource] + public sealed class Item : Identifiable { - [Resource] - public sealed class Item : Identifiable - { - [Attr] - public int Value { get; set; } - } - }") + [Attr] + public int Value { get; set; } + } + }") .Build(); Compilation inputCompilation = new CompilationBuilder() diff --git a/test/SourceGeneratorTests/SourceGeneratorTests.csproj b/test/SourceGeneratorTests/SourceGeneratorTests.csproj index 4284854c44..f9af731411 100644 --- a/test/SourceGeneratorTests/SourceGeneratorTests.csproj +++ b/test/SourceGeneratorTests/SourceGeneratorTests.csproj @@ -13,6 +13,5 @@ - diff --git a/test/TestBuildingBlocks/DbContextExtensions.cs b/test/TestBuildingBlocks/DbContextExtensions.cs index 8ce859f356..7f32073874 100644 --- a/test/TestBuildingBlocks/DbContextExtensions.cs +++ b/test/TestBuildingBlocks/DbContextExtensions.cs @@ -23,15 +23,15 @@ public static async Task ClearTablesAsync(this DbContext dbC await ClearTablesAsync(dbContext, typeof(TEntity1), typeof(TEntity2)); } - private static async Task ClearTablesAsync(this DbContext dbContext, params Type[] models) + private static async Task ClearTablesAsync(this DbContext dbContext, params Type[] modelTypes) { - foreach (Type model in models) + foreach (Type modelType in modelTypes) { - IEntityType? entityType = dbContext.Model.FindEntityType(model); + IEntityType? entityType = dbContext.Model.FindEntityType(modelType); if (entityType == null) { - throw new InvalidOperationException($"Table for '{model.Name}' not found."); + throw new InvalidOperationException($"Table for '{modelType.Name}' not found."); } string? tableName = entityType.GetTableName(); @@ -44,7 +44,7 @@ private static async Task ClearTablesAsync(this DbContext dbContext, params Type } else { - await dbContext.Database.ExecuteSqlRawAsync($"delete from \"{tableName}\""); + await dbContext.Database.ExecuteSqlRawAsync($"DELETE FROM \"{tableName}\""); } } } diff --git a/test/TestBuildingBlocks/IntegrationTestContext.cs b/test/TestBuildingBlocks/IntegrationTestContext.cs index 7856ba67f9..05679aad07 100644 --- a/test/TestBuildingBlocks/IntegrationTestContext.cs +++ b/test/TestBuildingBlocks/IntegrationTestContext.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Text.Json; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; @@ -6,6 +7,7 @@ using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -65,8 +67,8 @@ private WebApplicationFactory CreateFactory() { string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; - string dbConnectionString = $"Host=localhost;Port=5432;Database=JsonApiTest-{Guid.NewGuid():N};User ID=postgres;" + - $"Password={postgresPassword};Include Error Detail=true"; + string dbConnectionString = + $"Host=localhost;Database=JsonApiTest-{Guid.NewGuid():N};User ID=postgres;Password={postgresPassword};Include Error Detail=true"; var factory = new IntegrationTestWebApplicationFactory(); @@ -81,11 +83,7 @@ private WebApplicationFactory CreateFactory() services.AddDbContext(options => { options.UseNpgsql(dbConnectionString, builder => builder.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)); - -#if DEBUG - options.EnableSensitiveDataLogging(); - options.EnableDetailedErrors(); -#endif + SetDbContextDebugOptions(options); }); }); @@ -103,6 +101,14 @@ private WebApplicationFactory CreateFactory() return factoryWithConfiguredContentRoot; } + [Conditional("DEBUG")] + private static void SetDbContextDebugOptions(DbContextOptionsBuilder options) + { + options.EnableDetailedErrors(); + options.EnableSensitiveDataLogging(); + options.ConfigureWarnings(builder => builder.Ignore(CoreEventId.SensitiveDataLoggingEnabledWarning)); + } + public void ConfigureLogging(Action loggingConfiguration) { _loggingConfiguration = loggingConfiguration; diff --git a/test/TestBuildingBlocks/NoLoggingWebApplicationFactory.cs b/test/TestBuildingBlocks/NoLoggingWebApplicationFactory.cs new file mode 100644 index 0000000000..94e21f2db4 --- /dev/null +++ b/test/TestBuildingBlocks/NoLoggingWebApplicationFactory.cs @@ -0,0 +1,24 @@ +using System.Diagnostics; +using JetBrains.Annotations; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Logging; + +namespace TestBuildingBlocks; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class NoLoggingWebApplicationFactory : WebApplicationFactory + where TEntryPoint : class +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + DisableLogging(builder); + } + + [Conditional("RELEASE")] + private static void DisableLogging(IWebHostBuilder builder) + { + // Disable logging to keep the output from C/I build clean. Errors are expected to occur while testing failure handling. + builder.ConfigureLogging(loggingBuilder => loggingBuilder.ClearProviders()); + } +} diff --git a/test/TestBuildingBlocks/ObjectAssertionsExtensions.cs b/test/TestBuildingBlocks/ObjectAssertionsExtensions.cs index a295e1eaf9..ee2be771e1 100644 --- a/test/TestBuildingBlocks/ObjectAssertionsExtensions.cs +++ b/test/TestBuildingBlocks/ObjectAssertionsExtensions.cs @@ -2,6 +2,7 @@ using System.Text.Encodings.Web; using System.Text.Json; using FluentAssertions; +using FluentAssertions.Collections; using FluentAssertions.Numeric; using FluentAssertions.Primitives; using JetBrains.Annotations; @@ -65,4 +66,13 @@ private static string ToJsonString(JsonDocument document) writer.Flush(); return Encoding.UTF8.GetString(stream.ToArray()); } + + /// + /// Asserts that a "meta" dictionary contains a single element named "total" with the specified value. + /// + [CustomAssertion] + public static void ContainTotal(this GenericDictionaryAssertions, string, object?> source, int expectedTotal) + { + source.ContainKey("total").WhoseValue.Should().BeOfType().Subject.GetInt32().Should().Be(expectedTotal); + } } diff --git a/test/TestBuildingBlocks/TestBuildingBlocks.csproj b/test/TestBuildingBlocks/TestBuildingBlocks.csproj index 999498ebaa..46193372a8 100644 --- a/test/TestBuildingBlocks/TestBuildingBlocks.csproj +++ b/test/TestBuildingBlocks/TestBuildingBlocks.csproj @@ -10,9 +10,9 @@ - + - + diff --git a/test/TestBuildingBlocks/TestableDbContext.cs b/test/TestBuildingBlocks/TestableDbContext.cs index 18ef090baa..b92f6be261 100644 --- a/test/TestBuildingBlocks/TestableDbContext.cs +++ b/test/TestBuildingBlocks/TestableDbContext.cs @@ -15,7 +15,12 @@ protected TestableDbContext(DbContextOptions options) protected override void OnConfiguring(DbContextOptionsBuilder builder) { - // Writes SQL statements to the Output Window when debugging. + WriteSqlStatementsToOutputWindow(builder); + } + + [Conditional("DEBUG")] + private static void WriteSqlStatementsToOutputWindow(DbContextOptionsBuilder builder) + { builder.LogTo(message => Debug.WriteLine(message), DbLoggerCategory.Database.Name.AsArray(), LogLevel.Information); } diff --git a/test/TestBuildingBlocks/appsettings.json b/test/TestBuildingBlocks/appsettings.json index 160ba78e0f..c60110712a 100644 --- a/test/TestBuildingBlocks/appsettings.json +++ b/test/TestBuildingBlocks/appsettings.json @@ -2,10 +2,14 @@ "Logging": { "LogLevel": { "Default": "Warning", + // Disable logging to keep the output from C/I build clean. Errors are expected to occur while testing failure handling. + "Microsoft.AspNetCore.Hosting.Diagnostics": "None", "Microsoft.Hosting.Lifetime": "Warning", + "Microsoft.EntityFrameworkCore": "Warning", + "Microsoft.EntityFrameworkCore.Model.Validation": "Critical", "Microsoft.EntityFrameworkCore.Update": "Critical", "Microsoft.EntityFrameworkCore.Database.Command": "Critical", - "JsonApiDotNetCore.Middleware.JsonApiMiddleware": "Information" + "JsonApiDotNetCore": "Critical" } } }