-
+
-

+
diff --git a/docs/internals/toc.md b/docs/internals/toc.md
deleted file mode 100644
index 0533dc5272..0000000000
--- a/docs/internals/toc.md
+++ /dev/null
@@ -1 +0,0 @@
-# [Queries](queries.md)
diff --git a/docs/internals/toc.yml b/docs/internals/toc.yml
new file mode 100644
index 0000000000..adb35afc58
--- /dev/null
+++ b/docs/internals/toc.yml
@@ -0,0 +1,2 @@
+- name: Queries
+ href: queries.md
diff --git a/docs/request-examples/README.md b/docs/request-examples/README.md
index eb95ea4656..5a2911f5cb 100644
--- a/docs/request-examples/README.md
+++ b/docs/request-examples/README.md
@@ -2,18 +2,20 @@
To update these requests:
-1. Add a PowerShell (.ps1) script prefixed by a number that is used to determine the order the scripts are executed. The script should execute a request and output the response. Example:
-```
-curl -s http://localhost:14141/api/books
-```
+1. Add a PowerShell (`.ps1`) script prefixed by a number that is used to determine the order the scripts are executed.
+ The script should execute a request and output the response. For example:
+ ```
+ curl -s http://localhost:14141/api/books
+ ```
-2. Add the example to `index.md`. Example:
-```
-### Get with relationship
+2. Add the example to `index.md`. For example:
+ ```
+ ### Get with relationship
-[!code-ps[REQUEST](003_GET_Books-including-Author.ps1)]
-[!code-json[RESPONSE](003_GET_Books-including-Author_Response.json)]
-```
+ [!code-ps[REQUEST](003_GET_Books-including-Author.ps1)]
+ [!code-json[RESPONSE](003_GET_Books-including-Author_Response.json)]
+ ```
-3. Run `pwsh ../generate-examples.ps1`
-4. Verify the results by running `pwsh ../build-dev.ps1`
+3. Run `pwsh ../generate-examples.ps1` to execute the request.
+
+4. Run `pwsh ../build-dev.ps1` to view the output on the website.
diff --git a/docs/request-examples/index.md b/docs/request-examples/index.md
index 614aa4814f..89c7043450 100644
--- a/docs/request-examples/index.md
+++ b/docs/request-examples/index.md
@@ -1,17 +1,28 @@
----
-_disableToc: true
----
+# Example projects
-# Example requests
+Runnable example projects can be found [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/src/Examples):
+
+- GettingStarted: A simple project with minimal configuration to develop a runnable project in minutes.
+- JsonApiDotNetCoreExample: Showcases commonly-used features, such as resource definitions, atomic operations, and OpenAPI.
+ - OpenApiNSwagClientExample: Uses [NSwag](https://github.com/RicoSuter/NSwag) to generate a typed OpenAPI client.
+ - OpenApiKiotaClientExample: Uses [Kiota](https://learn.microsoft.com/en-us/openapi/kiota/) to generate a typed OpenAPI client.
+- MultiDbContextExample: Shows how to use multiple `DbContext` classes, for connecting to multiple databases.
+- DatabasePerTenantExample: Uses a different database per tenant. See [here](~/usage/advanced/multi-tenancy.md) for using multiple tenants in the same database.
+- NoEntityFrameworkExample: Uses a read-only in-memory repository, instead of a real database.
+- DapperExample: Uses [Dapper](https://github.com/DapperLib/Dapper) to execute SQL queries.
+- ReportsExample: Uses a resource service that returns aggregated data.
-These requests have been generated against the "GettingStarted" application and are updated on every deployment.
+> [!NOTE]
+> The example projects only cover highly-requested features. More advanced use cases can be found [here](~/usage/advanced/index.md).
+
+# Example requests
-All of these requests have been created using out-of-the-box features.
+The following requests are automatically generated against the "GettingStarted" application on every deployment.
> [!NOTE]
> curl requires "[" and "]" in URLs to be escaped.
-# Reading data
+## Reading data
### Get all
@@ -48,7 +59,7 @@ All of these requests have been created using out-of-the-box features.
[!code-ps[REQUEST](007_GET_Books-paginated.ps1)]
[!code-json[RESPONSE](007_GET_Books-paginated_Response.json)]
-# Writing data
+## Writing data
### Create resource
diff --git a/docs/request-examples/toc.md b/docs/request-examples/toc.md
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/docs/template/public/main.css b/docs/template/public/main.css
new file mode 100644
index 0000000000..a20926d93f
--- /dev/null
+++ b/docs/template/public/main.css
@@ -0,0 +1,6 @@
+/* From https://github.com/dotnet/docfx/discussions/9644 */
+
+body {
+ --bs-link-color-rgb: 66, 184, 131 !important;
+ --bs-link-hover-color-rgb: 64, 180, 128 !important;
+}
diff --git a/docs/template/public/main.js b/docs/template/public/main.js
new file mode 100644
index 0000000000..be4428bed6
--- /dev/null
+++ b/docs/template/public/main.js
@@ -0,0 +1,11 @@
+// From https://github.com/dotnet/docfx/discussions/9644
+
+export default {
+ iconLinks: [
+ {
+ icon: 'github',
+ href: 'https://github.com/json-api-dotnet/JsonApiDotNetCore',
+ title: 'GitHub'
+ }
+ ]
+}
diff --git a/docs/toc.yml b/docs/toc.yml
index e9165998e5..29f786ca4a 100644
--- a/docs/toc.yml
+++ b/docs/toc.yml
@@ -1,17 +1,12 @@
- name: Getting Started
- href: getting-started/
-
+ href: getting-started/index.md
- name: Usage
href: usage/
-
- name: API
href: api/
- homepage: api/index.md
-
+ topicHref: api/index.md
- name: Examples
- href: request-examples/
- homepage: request-examples/index.md
-
+ href: request-examples/index.md
- name: Internals
href: internals/
- homepage: internals/index.md
+ topicHref: internals/index.md
diff --git a/docs/usage/advanced/alternate-routes.md b/docs/usage/advanced/alternate-routes.md
new file mode 100644
index 0000000000..a860a61fa7
--- /dev/null
+++ b/docs/usage/advanced/alternate-routes.md
@@ -0,0 +1,8 @@
+# Alternate Routes
+
+The code [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes) shows how the default JSON:API routes can be changed.
+
+The classes `TownsController` and `CiviliansController`:
+- Are decorated with `[DisableRoutingConvention]` to turn off the default JSON:API routing convention.
+- Are decorated with the ASP.NET `[Route]` attribute to specify at which route the controller is exposed.
+- Are augmented with non-standard JSON:API action methods, whose `[HttpGet]` attributes specify a custom route.
diff --git a/docs/usage/advanced/archiving.md b/docs/usage/advanced/archiving.md
new file mode 100644
index 0000000000..3892877a52
--- /dev/null
+++ b/docs/usage/advanced/archiving.md
@@ -0,0 +1,14 @@
+# Archiving
+
+The code [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving) demonstrates how to implement archived resources.
+
+> [!TIP]
+> This scenario is comparable with [Soft Deletion](~/usage/advanced/soft-deletion.md).
+> The difference is that archived resources are accessible to JSON:API clients, whereas soft-deleted resources _never_ are.
+
+- Archived resources can be fetched by ID, but don't show up in searches by default.
+- Resources can only be created in a non-archived state and then archived/unarchived using a PATCH resource request.
+- The archive date is stored in the database, but cannot be modified through JSON:API.
+- To delete a resource, it must be archived first.
+
+This feature is implemented using a custom resource definition. It intercepts write operations and recursively scans incoming filters.
diff --git a/docs/usage/advanced/auth-scopes.md b/docs/usage/advanced/auth-scopes.md
new file mode 100644
index 0000000000..e37cb1b6ae
--- /dev/null
+++ b/docs/usage/advanced/auth-scopes.md
@@ -0,0 +1,10 @@
+# Authorization Scopes
+
+The code [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes) shows how scope-based authorization can be used.
+
+- For simplicity, this code assumes the granted scopes are passed in a plain-text HTTP header. A more realistic use case would be to obtain the scopes from an OAuth token.
+- The HTTP header lists which resource types can be read from and/or written to.
+- An [ASP.NET Action Filter](https://learn.microsoft.com/aspnet/core/mvc/controllers/filters) validates incoming JSON:API resource/relationship requests.
+ - The incoming request path is validated against the permitted read/write permissions per resource type.
+ - The resource types used in query string parameters are validated against the permitted set of resource types.
+- A customized operations controller verifies that all incoming operations are allowed.
diff --git a/docs/usage/advanced/blobs.md b/docs/usage/advanced/blobs.md
new file mode 100644
index 0000000000..d3d4525c66
--- /dev/null
+++ b/docs/usage/advanced/blobs.md
@@ -0,0 +1,9 @@
+# BLOBs
+
+The code [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/test/JsonApiDotNetCoreTests/IntegrationTests/Blobs) shows how Binary Large Objects (BLOBs) can be used.
+
+- The `ImageContainer` resource type contains nullable and non-nullable `byte[]` properties.
+- BLOBs are queried and persisted using Entity Framework Core.
+- The BLOB data is returned as a base-64 encoded string in the JSON response.
+
+Blobs are handled automatically; there's no need for custom code.
diff --git a/docs/usage/advanced/composite-keys.md b/docs/usage/advanced/composite-keys.md
new file mode 100644
index 0000000000..768a22a190
--- /dev/null
+++ b/docs/usage/advanced/composite-keys.md
@@ -0,0 +1,8 @@
+# Composite Keys
+
+The code [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys) shows how database tables with composite keys can be used.
+
+- The `DbContext` configures `Car` to have a composite primary key consisting of the `RegionId` and `LicensePlate` columns.
+- The `Car.Id` property is overridden to provide a unique ID for JSON:API. It is marked with `[NotMapped]`, meaning no `Id` column exists in the database table.
+- The `Engine` and `Dealership` resource types define relationships that generate composite foreign keys in the database.
+- A custom resource repository is used to rewrite IDs from filter/sort query string parameters into `RegionId` and `LicensePlate` lookups.
diff --git a/docs/usage/advanced/content-negotiation.md b/docs/usage/advanced/content-negotiation.md
new file mode 100644
index 0000000000..980b2e0b65
--- /dev/null
+++ b/docs/usage/advanced/content-negotiation.md
@@ -0,0 +1,15 @@
+# Content Negotiation
+
+The code [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation) demonstrates how content negotiation in JSON:API works.
+
+Additionally, the code [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions) provides
+a custom "server-time" JSON:API extension that returns the local or UTC server time in top-level `meta`.
+- This extension can be used in the `Accept` and `Content-Type` HTTP headers.
+- In a request body, the optional `useLocalTime` property in top-level `meta` indicates whether to return the local or UTC time.
+
+This feature is implemented using the following extensibility points:
+
+- At startup, the "server-time" extension is added in `JsonApiOptions`, which permits clients to use it.
+- A custom `JsonApiContentNegotiator` chooses which extensions are active for an incoming request, taking the "server-time" extension into account.
+- A custom `IDocumentAdapter` captures the incoming request body, providing access to the `useLocalTime` property in `meta`.
+- A custom `IResponseMeta` adds the server time to the response, depending on the activated extensions in `IJsonApiRequest` and the captured request body.
diff --git a/docs/usage/advanced/eager-loading.md b/docs/usage/advanced/eager-loading.md
new file mode 100644
index 0000000000..72e401c4f0
--- /dev/null
+++ b/docs/usage/advanced/eager-loading.md
@@ -0,0 +1,12 @@
+# Eager Loading Related Resources
+
+The code [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading) uses the `[EagerLoad]` attribute to facilitate calculated properties that depend on related resources.
+The related resources are fetched from the database, but not returned to the client unless explicitly requested using the `include` query string parameter.
+
+- The `Street` resource type uses `EagerLoad` on its `Buildings` to-many relationship because its `DoorTotalCount` calculated property depends on it.
+- The `Building` resource type uses `EagerLoad` on its `Windows` to-many relationship because its `WindowCount` calculated property depends on it.
+- The `Building` resource type uses `EagerLoad` on its `PrimaryDoor` to-one required relationship because its `PrimaryDoorColor` calculated property depends on it.
+ - Because this is a required relationship, special handling occurs in `Building`, `BuildingRepository`, and `BuildingDefinition`.
+- The `Building` resource type uses `EagerLoad` on its `SecondaryDoor` to-one optional relationship because its `SecondaryDoorColor` calculated property depends on it.
+
+As can be seen from the usages above, a chain of `EagerLoad` attributes can result in fetching a chain of related resources from the database.
diff --git a/docs/usage/advanced/error-handling.md b/docs/usage/advanced/error-handling.md
new file mode 100644
index 0000000000..c53b3f2669
--- /dev/null
+++ b/docs/usage/advanced/error-handling.md
@@ -0,0 +1,13 @@
+# Error Handling
+
+The code [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling) shows how to customize error handling.
+
+A user-defined exception, `ConsumerArticleIsNoLongerAvailableException`, is thrown from a resource service to demonstrate handling it.
+Note that this exception can be thrown from anywhere during request execution; a resource service is just used here for simplicity.
+
+To handle the user-defined exception, `AlternateExceptionHandler` inherits from `ExceptionHandler` to:
+- Customize the JSON:API error response by adding a `meta` entry when `ConsumerArticleIsNoLongerAvailableException` is thrown.
+- Indicate that `ConsumerArticleIsNoLongerAvailableException` must be logged at the Warning level.
+
+Additionally, the `ThrowingArticle.Status` property throws an `InvalidOperationException`.
+This triggers the default error handling because `AlternateExceptionHandler` delegates to its base class.
diff --git a/docs/usage/advanced/hosting-iis.md b/docs/usage/advanced/hosting-iis.md
new file mode 100644
index 0000000000..f452adaeec
--- /dev/null
+++ b/docs/usage/advanced/hosting-iis.md
@@ -0,0 +1,7 @@
+# Hosting in Internet Information Services (IIS)
+
+The code [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS) calls [UsePathBase](https://learn.microsoft.com/dotnet/api/microsoft.aspnetcore.builder.usepathbaseextensions.usepathbase) to simulate hosting in IIS.
+For details on how `UsePathBase` works, see [Understanding PathBase in ASP.NET Core](https://andrewlock.net/understanding-pathbase-in-aspnetcore/).
+
+- At startup, the line `app.UsePathBase("/iis-application-virtual-directory")` configures ASP.NET to use the base path.
+- `PaintingsController` uses a custom route to demonstrate that both features can be used together.
diff --git a/docs/usage/advanced/id-obfuscation.md b/docs/usage/advanced/id-obfuscation.md
new file mode 100644
index 0000000000..4012238c29
--- /dev/null
+++ b/docs/usage/advanced/id-obfuscation.md
@@ -0,0 +1,16 @@
+# ID Obfuscation
+
+The code [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation) shows how to use obfuscated IDs.
+They are typically used to prevent clients from guessing primary key values.
+
+All IDs sent by clients are transparently de-obfuscated into internal numeric values before accessing the database.
+Numeric IDs returned from the database are obfuscated before they are sent to the client.
+
+> [!NOTE]
+> An alternate solution is to use GUIDs instead of numeric primary keys in the database.
+
+ID obfuscation is achieved using the following extensibility points:
+
+- For simplicity, `HexadecimalCodec` is used to obfuscate numeric IDs to a hexadecimal format. A more realistic use case would be to use a symmetric crypto algorithm.
+- `ObfuscatedIdentifiable` acts as the base class for resource types, handling the obfuscation and de-obfuscation of IDs.
+- `ObfuscatedIdentifiableController` acts as the base class for controllers. It inherits from `BaseJsonApiController`, changing the `id` parameter in action methods to type `string`.
diff --git a/docs/usage/advanced/index.md b/docs/usage/advanced/index.md
new file mode 100644
index 0000000000..6bf9841dbe
--- /dev/null
+++ b/docs/usage/advanced/index.md
@@ -0,0 +1,19 @@
+# Advanced JSON:API features
+
+This topic goes beyond the basics of what's possible with JsonApiDotNetCore.
+
+Advanced use cases are provided in the form of integration tests [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/test/JsonApiDotNetCoreTests/IntegrationTests).
+This ensures they don't break during development of the framework.
+
+Each directory typically contains:
+
+- A set of resource types.
+- A `DbContext` class to register the resource types.
+- Fakers to generate deterministic test data.
+- Test classes that assert the feature works as expected.
+ - Entities are inserted into a randomly named PostgreSQL database.
+ - An HTTP request is sent.
+ - The returned response is asserted on.
+ - If applicable, the changes are fetched from the database and asserted on.
+
+To run/debug the integration tests, follow the steps in [README.md](https://github.com/json-api-dotnet/JsonApiDotNetCore#build-from-source).
diff --git a/docs/usage/advanced/links.md b/docs/usage/advanced/links.md
new file mode 100644
index 0000000000..d26be87563
--- /dev/null
+++ b/docs/usage/advanced/links.md
@@ -0,0 +1,19 @@
+# Links
+
+The code [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/test/JsonApiDotNetCoreTests/IntegrationTests/Links) shows various ways to configure which links are returned, and how they appear in responses.
+
+> [!TIP]
+> By default, absolute links are returned. To return relative links, set [JsonApiOptions.UseRelativeLinks](~/usage/options.md#relative-links) at startup.
+
+> [!TIP]
+> To add a global prefix to all routes, set `JsonApiOptions.Namespace` at startup.
+
+Which links to render can be configured globally in options, then overridden per resource type, and then overridden per relationship.
+
+- The `PhotoLocation` resource type turns off `TopLevelLinks` and `ResourceLinks`, and sets `RelationshipLinks` to `Related`.
+- The `PhotoLocation.Album` relationship turns off all links for this relationship.
+
+The various tests set `JsonApiOptions.Namespace` and `JsonApiOptions.UseRelativeLinks` to verify that the proper links are rendered.
+This can't be set in the tests directly for technical reasons, so they use different `Startup` classes to control this.
+
+Link rendering is fully controlled using attributes on your models. No further code is needed.
diff --git a/docs/usage/advanced/microservices.md b/docs/usage/advanced/microservices.md
new file mode 100644
index 0000000000..88e9cb08b9
--- /dev/null
+++ b/docs/usage/advanced/microservices.md
@@ -0,0 +1,22 @@
+# Microservices
+
+The code [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices) shows patterns commonly used in microservices architecture:
+
+- [Fire-and-forget](https://microservices.io/patterns/communication-style/messaging.html): Outgoing messages are sent to an external queue, without waiting for their processing to start. While this is the simplest solution, it is not very reliable when errors occur.
+- [Transactional Outbox Pattern](https://microservices.io/patterns/data/transactional-outbox.html): Outgoing messages are saved to a queue table within the same database transaction. A background job (omitted in this example) polls the queue table and sends the messages to an external queue.
+
+> [!TIP]
+> Potential external queue systems you could use are [RabbitMQ](https://www.rabbitmq.com/), [MassTransit](https://masstransit.io/),
+> [Azure Service Bus](https://learn.microsoft.com/en-us/azure/service-bus-messaging/service-bus-messaging-overview) and [Apache Kafka](https://kafka.apache.org/). However, this is beyond the scope of this topic.
+
+The `Messages` directory lists the functional messages that are created from incoming JSON:API requests, which are typically processed by an external system that handles messages from the queue.
+Each message has a unique ID and type, and is versioned to support gradual deployments.
+Example payloads of messages are: user created, user login name changed, user moved to group, group created, group renamed, etc.
+
+The abstract types `MessagingGroupDefinition` and `MessagingUserDefinition` are resource definitions that contain code shared by both patterns. They inspect the incoming request and produce one or more functional messages from it.
+The pattern-specific derived types inject their `DbContext`, which is used to query for additional information when determining what is being changed.
+
+> [!NOTE]
+> Because networks are inherently unreliable, systems that consume messages from an external queue should be [idempotent](https://microservices.io/patterns/communication-style/idempotent-consumer.html).
+> Several years ago, a [prototype](https://github.com/json-api-dotnet/JsonApiDotNetCore/pull/1132) was built to make JSON:API idempotent, but it was never finished due to a lack of community interest.
+> Please [open an issue](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/new?labels=enhancement) if idempotency matters to you.
diff --git a/docs/usage/advanced/model-state.md b/docs/usage/advanced/model-state.md
new file mode 100644
index 0000000000..0117cd72e3
--- /dev/null
+++ b/docs/usage/advanced/model-state.md
@@ -0,0 +1,14 @@
+# ASP.NET Model Validation
+
+The code [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState) shows how to use [ASP.NET Model Validation](https://learn.microsoft.com/aspnet/web-api/overview/formats-and-model-binding/model-validation-in-aspnet-web-api) attributes.
+
+> [!TIP]
+> See [Atomic Operations](~/usage/advanced/operations.md) for how to implement a custom model validator.
+
+The resource types are decorated with Model Validation attributes, such as `[Required]`, `[RegularExpression]`, `[MinLength]`, and `[Range]`.
+
+Only the fields that appear in a request body (partial POST/PATCH) are validated.
+When validation fails, the source pointer in the response indicates which attribute(s) are invalid.
+
+Model Validation is enabled by default, but can be [turned off in options](~/usage/options.md#modelstate-validation).
+Aside from adding validation attributes to your resource properties, no further code is needed.
diff --git a/docs/usage/advanced/multi-tenancy.md b/docs/usage/advanced/multi-tenancy.md
new file mode 100644
index 0000000000..d6e5b73f62
--- /dev/null
+++ b/docs/usage/advanced/multi-tenancy.md
@@ -0,0 +1,21 @@
+# Multi-tenancy
+
+The code [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy) shows how to handle multiple tenants in a single database.
+
+> [!TIP]
+> To use a different database per tenant, see [this](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/src/Examples/DatabasePerTenantExample) example instead.
+> Its `DbContext` dynamically sets the connection string per request. This requires the database structure to be identical for all tenants.
+
+The essence of implementing multi-tenancy within a single database is instructing Entity Framework Core to add implicit filters when entities are queried.
+See the usage of `HasQueryFilter` in the `DbContext` class. It injects an `ITenantProvider` to determine the active tenant for the current HTTP request.
+
+> [!NOTE]
+> For simplicity, this example uses a route parameter to indicate the active tenant.
+> Provide your own `ITenantProvider` to determine the tenant from somewhere else, such as the incoming OAuth token.
+
+The generic `MultiTenantResourceService` transparently sets the tenant ID when creating a new resource.
+Furthermore, it performs extra queries to ensure relationship changes apply to the current tenant, and to produce better error messages.
+
+While `MultiTenantResourceService` is used for both resource types, _only_ the `WebShop` resource type implements `IHasTenant`.
+The related resource type `WebProduct` does not. Because the products table has a foreign key to the (tenant-specific) shop it belongs to, it doesn't need a `TenantId` column.
+When a JSON:API request for web products executes, the `HasQueryFilter` in the `DbContext` ensures that only products belonging to the tenant-specific shop are returned.
diff --git a/docs/usage/advanced/operations.md b/docs/usage/advanced/operations.md
new file mode 100644
index 0000000000..aec2b9fe4d
--- /dev/null
+++ b/docs/usage/advanced/operations.md
@@ -0,0 +1,15 @@
+# Atomic Operations
+
+The code [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations) covers usage of the [Atomic Operations](https://jsonapi.org/ext/atomic/) extension, which enables sending multiple changes in a single request.
+
+- Operations for creating, updating, and deleting resources and relationships are shown.
+- If one of the operations fails, the transaction is rolled back.
+- Local IDs are used to reference resources created in a preceding operation within the same request.
+- A custom controller restricts which operations are allowed, per resource type.
+- The maximum number of operations per request can be configured at startup.
+- For efficiency, operations are validated upfront (before accessing the database). If validation fails, the list of all errors is returned.
+ - Takes [ASP.NET Model Validation](https://learn.microsoft.com/aspnet/web-api/overview/formats-and-model-binding/model-validation-in-aspnet-web-api) attributes into account.
+ - See `DateMustBeInThePastAttribute` for how to implement a custom model validator.
+- Various interactions with resource definitions are shown.
+
+The Atomic Operations extension is enabled after an operations controller is added to the project. No further code is needed.
diff --git a/docs/usage/advanced/query-string-functions.md b/docs/usage/advanced/query-string-functions.md
new file mode 100644
index 0000000000..214228d654
--- /dev/null
+++ b/docs/usage/advanced/query-string-functions.md
@@ -0,0 +1,23 @@
+# Query String Functions
+
+The code [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions) shows how to define custom functions that clients can use in JSON:API query string parameters.
+
+- IsUpperCase: Adds the `isUpperCase` function, which can be used in filters on `string` attributes.
+ - Returns whether the attribute value is uppercase.
+ - Example usage: `GET /blogs/1/posts?filter=and(isUpperCase(caption),not(isUpperCase(url)))`
+- StringLength: Adds the `length` function, which can be used in filters and sorts on `string` attributes.
+ - Returns the number of characters in the attribute value.
+ - Example filter usage: `GET /blogs?filter=greaterThan(length(title),'2')`
+ - Example sort usage: `GET /blogs/1/posts?sort=length(caption),-length(url)`
+- Sum: Adds the `sum` function, which can be used in filters.
+ - Returns the sum of the numeric attribute values in related resources.
+ - Example: `GET /blogPosts?filter=greaterThan(sum(comments,numStars),'4')`
+- TimeOffset: Adds the `timeOffset` function, which can be used in filters on `DateTime` attributes.
+ - Calculates the difference between the attribute value and the current date.
+ - A generic resource definition intercepts all filters, rewriting the usage of `timeOffset` into the equivalent filters on the target attribute.
+ - Example: `GET /reminders?filter=greaterOrEqual(remindsAt,timeOffset('+0:10:00'))`
+
+The basic pattern to implement a custom function is to:
+- Define a custom expression type, which inherits from one of the built-in expression types, such as `FilterExpression` or `FunctionExpression`.
+- Inherit from one of the built-in parsers, such as `FilterParser` or `SortParser`, to convert tokens to your custom expression type. Override the `ParseFilter` or `ParseFunction` method.
+- Inherit from one of the built-in query clause builders, such as `WhereClauseBuilder` or `OrderClauseBuilder`, to produce a LINQ expression for your custom expression type. Override the `DefaultVisit` method.
diff --git a/docs/usage/advanced/resource-injection.md b/docs/usage/advanced/resource-injection.md
new file mode 100644
index 0000000000..c4e82a40fd
--- /dev/null
+++ b/docs/usage/advanced/resource-injection.md
@@ -0,0 +1,11 @@
+# Injecting services in resource types
+
+The code [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection) shows how to inject services into resource types.
+
+Because Entity Framework Core doesn't support injecting arbitrary services into entity types (only a few special types), a workaround is used.
+Instead of injecting the desired services directly, the `DbContext` is injected, which injects the desired services and exposes them via properties.
+
+- The `PostOffice` and `GiftCertificate` resource types both inject the `DbContext` in their constructors.
+- The `DbContext` injects `TimeProvider` and exposes it through a property.
+- `GiftCertificate` obtains the `TimeProvider` via the `DbContext` property to calculate the value for its exposed `HasExpired` property, which depends on the current time.
+- `PostOffice` obtains the `TimeProvider` via the `DbContext` property to calculate the value for its exposed `IsOpen` property, which depends on the current time.
diff --git a/docs/usage/advanced/soft-deletion.md b/docs/usage/advanced/soft-deletion.md
new file mode 100644
index 0000000000..cebc18e91c
--- /dev/null
+++ b/docs/usage/advanced/soft-deletion.md
@@ -0,0 +1,15 @@
+# Soft Deletion
+
+The code [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion) demonstrates how to implement soft deletion of resources.
+
+> [!TIP]
+> This scenario is comparable with [Archiving](~/usage/advanced/archiving.md).
+> The difference is that soft-deleted resources are never accessible by JSON:API clients (despite still being stored in the database), whereas archived resources _are_ accessible.
+
+The essence of implementing soft deletion is instructing Entity Framework Core to add implicit filters when entities are queried.
+See the usage of `HasQueryFilter` in the `DbContext` class.
+
+The `ISoftDeletable` interface provides the `SoftDeletedAt` database column. The `Company` and `Department` resource types implement this interface to indicate they use soft deletion.
+
+The generic `SoftDeletionAwareResourceService` overrides the `DeleteAsync` method to soft-delete a resource instead of truly deleting it, if it implements `ISoftDeletable`.
+Furthermore, it performs extra queries to ensure relationship changes do not reference soft-deleted resources, and to produce better error messages.
diff --git a/docs/usage/advanced/state-machine.md b/docs/usage/advanced/state-machine.md
new file mode 100644
index 0000000000..371300995a
--- /dev/null
+++ b/docs/usage/advanced/state-machine.md
@@ -0,0 +1,11 @@
+# State Transitions in Resource Updates
+
+The code [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody) shows how to validate state transitions when updating a resource.
+
+This feature is implemented using a custom resource definition:
+
+- The `Workflow` resource type contains a `Stage` property of type `WorkflowStage`.
+- The `WorkflowStage` enumeration lists a workflow's possible states.
+- `WorkflowDefinition` contains a hard-coded stage transition table defining the valid transitions. For example, a workflow in stage `InProgress` can be changed to `OnHold` or `Canceled`, but not `Created`.
+ - The `OnPrepareWriteAsync` method is overridden to capture the stage currently stored in the database in the `_previousStage` private field.
+ - The `OnWritingAsync` method is overridden to verify whether the stage change is permitted. It consults the stage transition table to determine whether there's a path from `_previousStage` to the to-be-stored stage, producing an error if there isn't.
diff --git a/docs/usage/advanced/toc.yml b/docs/usage/advanced/toc.yml
new file mode 100644
index 0000000000..9d45cd04b3
--- /dev/null
+++ b/docs/usage/advanced/toc.yml
@@ -0,0 +1,38 @@
+- name: Authorization Scopes
+ href: auth-scopes.md
+- name: BLOBs
+ href: blobs.md
+- name: Microservices
+ href: microservices.md
+- name: Multi-tenancy
+ href: multi-tenancy.md
+- name: Atomic Operations
+ href: operations.md
+- name: Query String Functions
+ href: query-string-functions.md
+- name: Alternate Routes
+ href: alternate-routes.md
+- name: Content Negotiation
+ href: content-negotiation.md
+- name: Error Handling
+ href: error-handling.md
+- name: Hosting in IIS
+ href: hosting-iis.md
+- name: ID Obfuscation
+ href: id-obfuscation.md
+- name: Soft Deletion
+ href: soft-deletion.md
+- name: Archiving
+ href: archiving.md
+- name: ASP.NET Model Validation
+ href: model-state.md
+- name: State Transitions in Resource Updates
+ href: state-machine.md
+- name: Links
+ href: links.md
+- name: Composite Keys
+ href: composite-keys.md
+- name: Eager Loading
+ href: eager-loading.md
+- name: Injecting services in resource types
+ href: resource-injection.md
diff --git a/docs/usage/caching.md b/docs/usage/caching.md
index 28d6a6a36e..4243fd8be2 100644
--- a/docs/usage/caching.md
+++ b/docs/usage/caching.md
@@ -4,8 +4,8 @@ _since v4.2_
GET requests return an [ETag](https://developer.mozilla.org/docs/Web/HTTP/Headers/ETag) HTTP header, which can be used by the client in subsequent requests to save network bandwidth.
-Be aware that the returned ETag represents the entire response body (a 'resource' in HTTP terminology) for a request URL that includes the query string.
-This is unrelated to JSON:API resources. Therefore, we do not use ETags for optimistic concurrency.
+Be aware that the returned ETag represents the entire response body (a "resource" in HTTP terminology) for the full request URL, including the query string.
+A resource in HTTP is unrelated to a JSON:API resource. Therefore, we do not use ETags for optimistic concurrency.
Getting a list of resources returns an ETag:
@@ -26,7 +26,7 @@ ETag: "7FFF010786E2CE8FC901896E83870E00"
}
```
-The request is later resent using the received ETag. The server data has not changed at this point.
+The request is later resent using the same ETag received earlier. The server data has not changed at this point.
```http
GET /articles?sort=-lastModifiedAt HTTP/1.1
diff --git a/docs/usage/extensibility/toc.yml b/docs/usage/extensibility/toc.yml
new file mode 100644
index 0000000000..4a32581a60
--- /dev/null
+++ b/docs/usage/extensibility/toc.yml
@@ -0,0 +1,14 @@
+- name: Layer Overview
+ href: layer-overview.md
+- name: Resource Definitions
+ href: resource-definitions.md
+- name: Controllers
+ href: controllers.md
+- name: Resource Services
+ href: services.md
+- name: Resource Repositories
+ href: repositories.md
+- name: Middleware
+ href: middleware.md
+- name: Query Strings
+ href: query-strings.md
diff --git a/docs/usage/faq.md b/docs/usage/faq.md
new file mode 100644
index 0000000000..cbb32c4c00
--- /dev/null
+++ b/docs/usage/faq.md
@@ -0,0 +1,176 @@
+# Frequently Asked Questions
+
+#### Where can I find documentation and examples?
+The [documentation](~/usage/resources/index.md) covers basic features, as well as [advanced use cases](~/usage/advanced/index.md). Several runnable example projects are available [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/src/Examples).
+
+#### Why don't you use the built-in OpenAPI support in ASP.NET Core?
+The structure of JSON:API request and response bodies differs significantly from the signature of JsonApiDotNetCore controllers.
+JsonApiDotNetCore provides OpenAPI support using [Swashbuckle](https://github.com/domaindrivendev/Swashbuckle.AspNetCore), a mature and feature-rich library that is highly extensible.
+The [OpenAPI support in ASP.NET Core](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/openapi/overview) is still very young
+and doesn't provide the level of extensibility needed for JsonApiDotNetCore.
+
+#### What's available to implement a JSON:API client?
+To generate a typed client (specific to the resource types in your project), consider using our [OpenAPI](https://www.jsonapi.net/usage/openapi.html) NuGet package.
+
+If you need a generic client, it depends on the programming language used. There's an overwhelming list of client libraries at https://jsonapi.org/implementations/#client-libraries.
+
+The JSON object model inside JsonApiDotNetCore is tweaked for server-side handling (be tolerant at inputs and strict at outputs).
+While you technically *could* use our `JsonSerializer` converters from a .NET client application with some hacks, we don't recommend doing so.
+You'll need to build the resource graph on the client and rely on internal implementation details that are subject to change in future versions.
+
+#### How can I debug my API project?
+Due to auto-generated controllers, you may find it hard to determine where to put your breakpoints.
+In Visual Studio, controllers are accessible below **Solution Explorer > Project > Dependencies > Analyzers > JsonApiDotNetCore.SourceGenerators**.
+
+After turning on [Source Link](https://devblogs.microsoft.com/dotnet/improving-debug-time-productivity-with-source-link/#enabling-source-link) (which enables to download the JsonApiDotNetCore source code from GitHub), you can step into our source code and add breakpoints there too.
+
+Here are some key places in the execution pipeline to set a breakpoint:
+- `JsonApiRoutingConvention.Apply`: Controllers are registered here (executes once at startup)
+- `JsonApiMiddleware.InvokeAsync`: Content negotiation and `IJsonApiRequest` setup
+- `QueryStringReader.ReadAll`: Parses the query string parameters
+- `JsonApiReader.ReadAsync`: Parses the request body
+- `OperationsProcessor.ProcessAsync`: Entry point for handling atomic operations
+- `JsonApiResourceService`: Called by controllers, delegating to the repository layer
+- `EntityFrameworkCoreRepository.ApplyQueryLayer`: Builds the `IQueryable<>` that is offered to Entity Framework Core (which turns it into SQL)
+- `JsonApiWriter.WriteAsync`: Renders the response body
+- `ExceptionHandler.HandleException`: Interception point for thrown exceptions
+
+Aside from debugging, you can get more info by:
+- Including exception stack traces and incoming request bodies in error responses, as well as writing human-readable JSON:
+
+ ```c#
+ // Program.cs
+ builder.Services.AddJsonApi
(options =>
+ {
+ options.IncludeExceptionStackTraceInErrors = true;
+ options.IncludeRequestBodyInErrors = true;
+ options.SerializerOptions.WriteIndented = true;
+ });
+ ```
+- Turning on trace logging, or/and logging of executed SQL statements, by adding the following to your `appsettings.Development.json`:
+
+ ```json
+ {
+ "Logging": {
+ "LogLevel": {
+ "Default": "Warning",
+ "Microsoft.EntityFrameworkCore.Database.Command": "Information",
+ "JsonApiDotNetCore": "Trace"
+ }
+ }
+ }
+ ```
+- Activate debug logging of LINQ expressions by adding a NuGet reference to [AgileObjects.ReadableExpressions](https://www.nuget.org/packages/AgileObjects.ReadableExpressions) in your project.
+
+#### What if my JSON:API resources do not exactly match the shape of my database tables?
+We often find users trying to write custom code to solve that. They usually get it wrong or incomplete, and it may not perform well.
+Or it simply fails because it cannot be translated to SQL.
+The good news is that there's an easier solution most of the time: configure Entity Framework Core mappings to do the work.
+
+For example, if your primary key column is named "CustomerId" instead of "Id":
+```c#
+builder.Entity().Property(x => x.Id).HasColumnName("CustomerId");
+```
+
+It certainly pays off to read up on these capabilities at [Creating and Configuring a Model](https://learn.microsoft.com/ef/core/modeling/).
+Another great resource is [Learn Entity Framework Core](https://www.learnentityframeworkcore.com/configuration).
+
+#### Can I share my resource models with .NET Framework projects?
+Yes, you can. Put your model classes in a separate project that only references [JsonApiDotNetCore.Annotations](https://www.nuget.org/packages/JsonApiDotNetCore.Annotations/).
+This package contains just the JSON:API attributes and targets NetStandard 1.0, which makes it flexible to consume.
+At startup, use [Auto-discovery](~/usage/resource-graph.md#auto-discovery) and point it to your shared project.
+
+#### What's the best place to put my custom business/validation logic?
+For basic input validation, use the attributes from [ASP.NET ModelState Validation](https://learn.microsoft.com/aspnet/core/mvc/models/validation?source=recommendations&view=aspnetcore-7.0#built-in-attributes) to get the best experience.
+JsonApiDotNetCore is aware of them and adjusts behavior accordingly. And it produces the best possible error responses.
+
+For non-trivial business rules that require custom code, the place to be is [Resource Definitions](~/usage/extensibility/resource-definitions.md).
+They provide a callback-based model where you can respond to everything going on.
+The great thing is that your callbacks are invoked for various endpoints.
+For example, the filter callback on Author executes at `GET /authors?filter=`, `GET /books/1/authors?filter=` and `GET /books?include=authors?filter[authors]=`.
+Likewise, the callbacks for changing relationships execute for POST/PATCH resource endpoints, as well as POST/PATCH/DELETE relationship endpoints.
+
+#### Can API users send multiple changes in a single request?
+Yes, just activate [atomic operations](~/usage/writing/bulk-batch-operations.md).
+It enables sending multiple changes in a batch request, which are executed in a database transaction.
+If something fails, all changes are rolled back. The error response indicates which operation failed.
+
+#### Is there any way to add `[Authorize(Roles = "...")]` to the generated controllers?
+Sure, this is possible. Simply add the attribute at the class level.
+See the docs on [Augmenting controllers](~/usage/extensibility/controllers.md#augmenting-controllers).
+
+#### How do I expose non-JSON:API endpoints?
+You can add your own controllers that do not derive from `(Base)JsonApiController` or `(Base)JsonApiOperationsController`.
+Whatever you do in those is completely ignored by JsonApiDotNetCore.
+This is useful if you want to add a few RPC-style endpoints or provide binary file uploads/downloads.
+
+A middle-ground approach is to add custom action methods to existing JSON:API controllers.
+While you can route them as you like, they must return JSON:API resources.
+And on error, a JSON:API error response is produced.
+This is useful if you want to stay in the JSON:API-compliant world, but need to expose something non-standard, for example: `GET /users/me`.
+
+#### How do I optimize for high scalability and prevent denial of service?
+Fortunately, JsonApiDotNetCore [scales pretty well](https://github.com/json-api-dotnet/PerformanceReports) under high load and/or large database tables.
+It never executes filtering, sorting, or pagination in-memory and tries pretty hard to produce the most efficient query possible.
+There are a few things to keep in mind, though:
+- Prevent users from executing slow queries by locking down [attribute capabilities](~/usage/resources/attributes.md#capabilities) and [relationship capabilities](~/usage/resources/relationships.md#capabilities).
+ Ensure the right database indexes are in place for what you enable.
+- Prevent users from fetching lots of data by tweaking [maximum page size/number](~/usage/options.md#pagination) and [maximum include depth](~/usage/options.md#maximum-include-depth).
+- Avoid long-running transactions by tweaking `MaximumOperationsPerRequest` in options.
+- Tell your users to utilize [E-Tags](~/usage/caching.md) to reduce network traffic.
+- Not included in JsonApiDotNetCore: Apply general practices such as rate limiting, load balancing, authentication/authorization, blocking very large URLs/request bodies, etc.
+
+#### Can I offload requests to a background process?
+Yes, that's possible. Override controller methods to return `HTTP 202 Accepted`, with a `Location` HTTP header where users can retrieve the result.
+Your controller method needs to store the request state (URL, query string, and request body) in a queue, which your background process can read from.
+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?
+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.
+
+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.
+- `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, 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 or analyze query strings.
+And most resource definition callbacks are handled.
+That's because the built-in resource service translates all JSON:API query 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 can probably reuse [QueryableBuilder](https://github.com/json-api-dotnet/JsonApiDotNetCore/blob/master/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilder.cs),
+which drives the translation into [System.Linq.Expressions](https://learn.microsoft.com/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 need to
+[prevent that from happening](https://github.com/json-api-dotnet/JsonApiDotNetCore/blob/master/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryLayerIncludeConverter.cs).
+
+The example [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/blob/master/src/Examples/NoEntityFrameworkExample/Repositories/InMemoryResourceRepository.cs) compiles and executes
+the LINQ query against an in-memory list of resources.
+For [MongoDB](https://github.com/json-api-dotnet/JsonApiDotNetCore.MongoDb/blob/master/src/JsonApiDotNetCore.MongoDb/Repositories/MongoRepository.cs), we use the MongoDB LINQ provider.
+If there's no LINQ provider available, the example [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/blob/master/src/Examples/DapperExample/Repositories/DapperRepository.cs) may be of help,
+which produces SQL and uses [Dapper](https://github.com/DapperLib/Dapper) for data access.
+
+> [!TIP]
+> [ReadableExpressions](https://github.com/agileobjects/ReadableExpressions) is very helpful in trying to debug LINQ expression trees!
+
+#### I love JsonApiDotNetCore! How can I support the team?
+The best way to express your gratitude is by starring our repository.
+This increases our leverage when asking for bug fixes in dependent projects, such as the .NET runtime and Entity Framework Core.
+You can also [sponsor](https://github.com/sponsors/json-api-dotnet) our project.
+Of course, a simple thank-you message in our [Gitter channel](https://gitter.im/json-api-dotnet-core/Lobby) is appreciated too!
+
+If you'd like to do more: try things out, ask questions, create GitHub bug reports or feature requests, or upvote existing issues that are important to you.
+We welcome PRs, but keep in mind: The worst thing in the world is opening a PR that gets rejected after you've put a lot of effort into it.
+So for any non-trivial changes, please open an issue first to discuss your approach and ensure it fits the product vision.
+
+#### Is there anything else I should be aware of?
+See [Common Pitfalls](~/usage/common-pitfalls.md).
diff --git a/docs/usage/openapi-client.md b/docs/usage/openapi-client.md
index 58b9ca87e9..5dc40ce6fc 100644
--- a/docs/usage/openapi-client.md
+++ b/docs/usage/openapi-client.md
@@ -340,7 +340,6 @@ demonstrates how to use them. It uses local IDs to:
| --- | --- | --- |
| Properties are always nullable | - | https://github.com/microsoft/kiota/issues/3911 |
| JSON:API query strings are inaccessible | Use `SetQueryStringHttpMessageHandler.CreateScope` from `JsonApiDotNetCore.OpenApi.Client.Kiota` package | https://github.com/microsoft/kiota/issues/3800 |
-| Properties set to `null` are sent twice | - | https://github.com/microsoft/kiota-dotnet/issues/535 |
| HTTP 304 (Not Modified) is not properly recognized | Catch `ApiException` and inspect the response status code | https://github.com/microsoft/kiota/issues/4190, https://github.com/microsoft/kiota-dotnet/issues/531 |
| Generator warns about unsupported formats | Use `JsonApiDotNetCore.OpenApi.Client.Kiota` package | https://github.com/microsoft/kiota/issues/4227 |
| `Stream` response for HEAD request | - | https://github.com/microsoft/kiota/issues/4245 |
diff --git a/docs/usage/options.md b/docs/usage/options.md
index 7e89ff0090..c78e9584e1 100644
--- a/docs/usage/options.md
+++ b/docs/usage/options.md
@@ -59,6 +59,9 @@ 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 pagination links are returned instead. This means no `last` link and the `next` link only occurs when the current page is full.
+> [!TIP]
+> Since v5.8, pagination can be [turned off per relationship](~/usage/resources/relationships.md#disable-pagination).
+
## Relative Links
All links are absolute by default. However, you can configure relative links:
diff --git a/docs/usage/reading/pagination.md b/docs/usage/reading/pagination.md
index ea4e30e621..dd02662c67 100644
--- a/docs/usage/reading/pagination.md
+++ b/docs/usage/reading/pagination.md
@@ -18,6 +18,13 @@ and on included resources, for example:
GET /api/blogs/1/articles?include=revisions&page[size]=10,revisions:5&page[number]=2,revisions:3 HTTP/1.1
```
+> [!NOTE]
+> For optimal performance, pagination links and total meta are not returned for *included* to-many relationships.
+> See [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1738) for details.
+
## Configuring Default Behavior
You can configure the global default behavior as described [here](~/usage/options.md#pagination).
+
+> [!TIP]
+> Since v5.8, pagination can be [turned off per relationship](~/usage/resources/relationships.md#disable-pagination).
diff --git a/docs/usage/reading/toc.yml b/docs/usage/reading/toc.yml
new file mode 100644
index 0000000000..aa1ecb6bca
--- /dev/null
+++ b/docs/usage/reading/toc.yml
@@ -0,0 +1,10 @@
+- name: Filtering
+ href: filtering.md
+- name: Sorting
+ href: sorting.md
+- name: Pagination
+ href: pagination.md
+- name: Sparse Fieldset Selection
+ href: sparse-fieldset-selection.md
+- name: Including Related Resources
+ href: including-relationships.md
diff --git a/docs/usage/resources/index.md b/docs/usage/resources/index.md
index f8e7d29156..09e0224c57 100644
--- a/docs/usage/resources/index.md
+++ b/docs/usage/resources/index.md
@@ -22,10 +22,9 @@ public class Person : Identifiable
}
```
-If your resource must inherit from another class,
-you can always implement the interface yourself.
-In this example, `ApplicationUser` inherits from `IdentityUser`
-which already contains an Id property of type string.
+If your resource must inherit from another class, you can always implement the interface yourself.
+In this example, `ApplicationUser` inherits from [`IdentityUser`](https://learn.microsoft.com/dotnet/api/microsoft.aspnetcore.identity.entityframeworkcore.identityuser),
+which already contains an `Id` property of type `string`.
```c#
public class ApplicationUser : IdentityUser, IIdentifiable
diff --git a/docs/usage/resources/inheritance.md b/docs/usage/resources/inheritance.md
index 47cf85ca67..56c046ef82 100644
--- a/docs/usage/resources/inheritance.md
+++ b/docs/usage/resources/inheritance.md
@@ -143,7 +143,7 @@ GET /humans HTTP/1.1
}
```
-### Spare fieldsets
+### Sparse fieldsets
If you only want to retrieve the fields from the base type, you can use [sparse fieldsets](~/usage/reading/sparse-fieldset-selection.md).
diff --git a/docs/usage/resources/relationships.md b/docs/usage/resources/relationships.md
index f318b2ddcd..b8c563e94e 100644
--- a/docs/usage/resources/relationships.md
+++ b/docs/usage/resources/relationships.md
@@ -213,6 +213,17 @@ public class Person : Identifiable
The left side of this relationship is of type `Person` (public name: "persons") and the right side is of type `TodoItem` (public name: "todoItems").
+### Disable pagination
+
+_since v5.8_
+
+Pagination can be turned off per to-many relationship by setting `DisablePagination` to `true`.
+When doing so, it overrules the global pagination settings in options, and any pagination used in the query string
+for the relationship.
+
+This feature exists for cases where the number of *related* resources is typically small.
+For example, while the number of products is usually high, the number of products *in a shopping basket* is not.
+
## HasManyThrough
_removed since v5.0_
diff --git a/docs/usage/resources/toc.yml b/docs/usage/resources/toc.yml
new file mode 100644
index 0000000000..d4daf205d4
--- /dev/null
+++ b/docs/usage/resources/toc.yml
@@ -0,0 +1,8 @@
+- name: Attributes
+ href: attributes.md
+- name: Relationships
+ href: relationships.md
+- name: Inheritance
+ href: inheritance.md
+- name: Nullability
+ href: nullability.md
diff --git a/docs/usage/toc.md b/docs/usage/toc.md
deleted file mode 100644
index bdeb0e4958..0000000000
--- a/docs/usage/toc.md
+++ /dev/null
@@ -1,39 +0,0 @@
-# [Resources](resources/index.md)
-## [Attributes](resources/attributes.md)
-## [Relationships](resources/relationships.md)
-## [Inheritance](resources/inheritance.md)
-## [Nullability](resources/nullability.md)
-
-# Reading data
-## [Filtering](reading/filtering.md)
-## [Sorting](reading/sorting.md)
-## [Pagination](reading/pagination.md)
-## [Sparse Fieldset Selection](reading/sparse-fieldset-selection.md)
-## [Including Relationships](reading/including-relationships.md)
-
-# Writing data
-## [Creating](writing/creating.md)
-## [Updating](writing/updating.md)
-## [Deleting](writing/deleting.md)
-## [Bulk/batch](writing/bulk-batch-operations.md)
-
-# [Resource Graph](resource-graph.md)
-# [Options](options.md)
-# [Routing](routing.md)
-# [Errors](errors.md)
-# [Metadata](meta.md)
-# [Caching](caching.md)
-# [Common Pitfalls](common-pitfalls.md)
-
-# [OpenAPI](openapi.md)
-## [Documentation](openapi-documentation.md)
-## [Clients](openapi-client.md)
-
-# Extensibility
-## [Layer Overview](extensibility/layer-overview.md)
-## [Resource Definitions](extensibility/resource-definitions.md)
-## [Controllers](extensibility/controllers.md)
-## [Resource Services](extensibility/services.md)
-## [Resource Repositories](extensibility/repositories.md)
-## [Middleware](extensibility/middleware.md)
-## [Query Strings](extensibility/query-strings.md)
diff --git a/docs/usage/toc.yml b/docs/usage/toc.yml
new file mode 100644
index 0000000000..f5d60e9a1f
--- /dev/null
+++ b/docs/usage/toc.yml
@@ -0,0 +1,35 @@
+- name: FAQ
+ href: faq.md
+- name: Common Pitfalls
+ href: common-pitfalls.md
+- name: Resources
+ href: resources/toc.yml
+ topicHref: resources/index.md
+- name: Reading data
+ href: reading/toc.yml
+- name: Writing data
+ href: writing/toc.yml
+- name: Resource Graph
+ href: resource-graph.md
+- name: Options
+ href: options.md
+- name: Routing
+ href: routing.md
+- name: Errors
+ href: errors.md
+- name: Metadata
+ href: meta.md
+- name: Caching
+ href: caching.md
+- name: OpenAPI
+ href: openapi.md
+ items:
+ - name: Documentation
+ href: openapi-documentation.md
+ - name: Clients
+ href: openapi-client.md
+- name: Extensibility
+ href: extensibility/toc.yml
+- name: Advanced
+ href: advanced/toc.yml
+ topicHref: advanced/index.md
diff --git a/docs/usage/writing/toc.yml b/docs/usage/writing/toc.yml
new file mode 100644
index 0000000000..db836e548f
--- /dev/null
+++ b/docs/usage/writing/toc.yml
@@ -0,0 +1,8 @@
+- name: Creating
+ href: creating.md
+- name: Updating
+ href: updating.md
+- name: Deleting
+ href: deleting.md
+- name: Bulk/Batch
+ href: bulk-batch-operations.md
diff --git a/package-versions.props b/package-versions.props
index 8b528ff324..e77a2ae86b 100644
--- a/package-versions.props
+++ b/package-versions.props
@@ -4,14 +4,15 @@
4.1.0
0.4.1
2.14.1
- 8.0.0
13.0.3
+ 9.0.3
+ 4.3.1
- 0.14.*
+ 0.15.*
1.0.*
35.6.*
- 4.13.*
+ 4.14.*
6.0.*
2.1.*
7.2.*
@@ -20,14 +21,16 @@
1.*
9.0.*
9.0.*
- 14.3.*
+ 0.9.*
+ 14.5.*
13.0.*
- 2.1.*
- 8.*-*
+ 4.1.*
+ 2.6.*
+ 9.*-*
9.0.*
- 17.13.*
+ 17.14.*
2.9.*
- 2.8.*
+ 3.1.*
diff --git a/run-docker-postgres.ps1 b/run-docker-postgres.ps1
index 0cd42b3893..25b631a7ad 100644
--- a/run-docker-postgres.ps1
+++ b/run-docker-postgres.ps1
@@ -11,7 +11,7 @@ param(
docker container stop jsonapi-postgresql-db
docker container stop jsonapi-postgresql-management
-docker run --pull always --rm --detach --name jsonapi-postgresql-db -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres:latest
+docker run --pull always --rm --detach --name jsonapi-postgresql-db -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres:latest -N 500
if ($UI) {
docker run --pull always --rm --detach --name jsonapi-postgresql-management --link jsonapi-postgresql-db:db -e PGADMIN_DEFAULT_EMAIL=admin@admin.com -e PGADMIN_DEFAULT_PASSWORD=postgres -p 5050:80 dpage/pgadmin4:latest
diff --git a/src/Examples/DapperExample/AtomicOperations/AmbientTransactionFactory.cs b/src/Examples/DapperExample/AtomicOperations/AmbientTransactionFactory.cs
index 82790819fe..715310e04f 100644
--- a/src/Examples/DapperExample/AtomicOperations/AmbientTransactionFactory.cs
+++ b/src/Examples/DapperExample/AtomicOperations/AmbientTransactionFactory.cs
@@ -26,7 +26,7 @@ public AmbientTransactionFactory(IJsonApiOptions options, IDataModelService data
internal async Task BeginTransactionAsync(CancellationToken cancellationToken)
{
- var instance = (IOperationsTransactionFactory)this;
+ IOperationsTransactionFactory instance = this;
IOperationsTransaction transaction = await instance.BeginTransactionAsync(cancellationToken);
return (AmbientTransaction)transaction;
diff --git a/src/Examples/GettingStarted/GettingStarted.http b/src/Examples/GettingStarted/GettingStarted.http
new file mode 100644
index 0000000000..271f493a15
--- /dev/null
+++ b/src/Examples/GettingStarted/GettingStarted.http
@@ -0,0 +1,85 @@
+@hostAddress = http://localhost:14141
+
+### Get all books with their authors.
+
+GET {{hostAddress}}/api/books?include=author
+
+### Get the first two books.
+
+GET {{hostAddress}}/api/books?page[size]=2
+
+### Filter books whose title contains whitespace, sort descending by publication year.
+
+GET {{hostAddress}}/api/books?filter=contains(title,'%20')&sort=-publishYear
+
+### Get only the titles of all books.
+
+GET {{hostAddress}}/api/books?fields[books]=title
+
+### Get the names of all people.
+
+GET {{hostAddress}}/api/people?fields[people]=name
+
+### Create a new person.
+
+POST {{hostAddress}}/api/people
+Content-Type: application/vnd.api+json
+
+{
+ "data": {
+ "type": "people",
+ "attributes": {
+ "name": "Alice"
+ }
+ }
+}
+
+### Create a new book, authored by the created person.
+
+POST {{hostAddress}}/api/books
+Content-Type: application/vnd.api+json
+
+{
+ "data": {
+ "type": "books",
+ "attributes": {
+ "title": "Getting started with JSON:API",
+ "publishYear": 2000
+ },
+ "relationships": {
+ "author": {
+ "data": {
+ "type": "people",
+ "id": "4"
+ }
+ }
+ }
+ }
+}
+
+### Change the publication year and author of the book with ID 1.
+
+PATCH {{hostAddress}}/api/books/1
+Content-Type: application/vnd.api+json
+
+{
+ "data": {
+ "type": "books",
+ "id": "1",
+ "attributes": {
+ "publishYear": 1820
+ },
+ "relationships": {
+ "author": {
+ "data": {
+ "type": "people",
+ "id": "4"
+ }
+ }
+ }
+ }
+}
+
+### Delete the book with ID 1.
+
+DELETE {{hostAddress}}/api/books/1
diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/NonJsonApiController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/NonJsonApiController.cs
index aa51110869..8d072b1ec1 100644
--- a/src/Examples/JsonApiDotNetCoreExample/Controllers/NonJsonApiController.cs
+++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/NonJsonApiController.cs
@@ -3,9 +3,13 @@
namespace JsonApiDotNetCoreExample.Controllers;
[Route("[controller]")]
+[Tags("nonJsonApi")]
public sealed class NonJsonApiController : ControllerBase
{
- [HttpGet]
+ [HttpGet(Name = "welcomeGet")]
+ [HttpHead(Name = "welcomeHead")]
+ [EndpointDescription("Returns a single-element JSON array.")]
+ [ProducesResponseType>(StatusCodes.Status200OK, "application/json")]
public IActionResult Get()
{
string[] result = ["Welcome!"];
@@ -14,12 +18,15 @@ public IActionResult Get()
}
[HttpPost]
- public async Task PostAsync()
+ [EndpointDescription("Returns a greeting text, based on your name.")]
+ [Consumes("application/json")]
+ [ProducesResponseType(StatusCodes.Status200OK, "text/plain")]
+ [ProducesResponseType(StatusCodes.Status400BadRequest, "text/plain")]
+ public async Task PostAsync([FromBody] string? name)
{
- using var reader = new StreamReader(Request.Body, leaveOpen: true);
- string name = await reader.ReadToEndAsync();
+ await Task.Yield();
- if (string.IsNullOrEmpty(name))
+ if (string.IsNullOrWhiteSpace(name))
{
return BadRequest("Please send your name.");
}
@@ -29,14 +36,18 @@ public async Task PostAsync()
}
[HttpPut]
- public IActionResult Put([FromBody] string name)
+ [EndpointDescription("Returns another greeting text.")]
+ [ProducesResponseType(StatusCodes.Status200OK, "text/plain")]
+ public IActionResult Put([FromQuery] string? name)
{
string result = $"Hi, {name}";
return Ok(result);
}
[HttpPatch]
- public IActionResult Patch(string name)
+ [EndpointDescription("Wishes you a good day.")]
+ [ProducesResponseType(StatusCodes.Status200OK, "text/plain")]
+ public IActionResult Patch([FromHeader] string? name)
{
string result = $"Good day, {name}";
return Ok(result);
diff --git a/src/Examples/JsonApiDotNetCoreExample/GeneratedSwagger/JsonApiDotNetCoreExample.json b/src/Examples/JsonApiDotNetCoreExample/GeneratedSwagger/JsonApiDotNetCoreExample.json
index 4863000598..15fe87de78 100644
--- a/src/Examples/JsonApiDotNetCoreExample/GeneratedSwagger/JsonApiDotNetCoreExample.json
+++ b/src/Examples/JsonApiDotNetCoreExample/GeneratedSwagger/JsonApiDotNetCoreExample.json
@@ -10,6 +10,143 @@
}
],
"paths": {
+ "/NonJsonApi": {
+ "get": {
+ "tags": [
+ "nonJsonApi"
+ ],
+ "description": "Returns a single-element JSON array.",
+ "operationId": "welcomeGet",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "head": {
+ "tags": [
+ "nonJsonApi"
+ ],
+ "description": "Returns a single-element JSON array.",
+ "operationId": "welcomeHead",
+ "responses": {
+ "200": {
+ "description": "OK"
+ }
+ }
+ },
+ "post": {
+ "tags": [
+ "nonJsonApi"
+ ],
+ "description": "Returns a greeting text, based on your name.",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "text/plain": {
+ "schema": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "content": {
+ "text/plain": {
+ "schema": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "put": {
+ "tags": [
+ "nonJsonApi"
+ ],
+ "description": "Returns another greeting text.",
+ "parameters": [
+ {
+ "name": "name",
+ "in": "query",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "text/plain": {
+ "schema": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "patch": {
+ "tags": [
+ "nonJsonApi"
+ ],
+ "description": "Wishes you a good day.",
+ "parameters": [
+ {
+ "name": "name",
+ "in": "header",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "text/plain": {
+ "schema": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "nonJsonApi"
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ }
+ }
+ }
+ },
"/api/operations": {
"post": {
"tags": [
diff --git a/src/Examples/JsonApiDotNetCoreExample/JsonApiDotNetCoreExample.http b/src/Examples/JsonApiDotNetCoreExample/JsonApiDotNetCoreExample.http
new file mode 100644
index 0000000000..6ea166d4e3
--- /dev/null
+++ b/src/Examples/JsonApiDotNetCoreExample/JsonApiDotNetCoreExample.http
@@ -0,0 +1,47 @@
+@hostAddress = https://localhost:44340
+
+### Gets all high-priority todo-items, including their owner, assignee and tags.
+
+GET {{hostAddress}}/api/todoItems?include=owner,assignee,tags&filter=equals(priority,'High')
+
+### Creates a todo-item, linking it to an existing owner, assignee and tags.
+
+POST {{hostAddress}}/api/todoItems
+Content-Type: application/vnd.api+json
+
+{
+ "data": {
+ "type": "todoItems",
+ "attributes": {
+ "description": "Create release",
+ "priority": "High",
+ "durationInHours": 1
+ },
+ "relationships": {
+ "owner": {
+ "data": {
+ "type": "people",
+ "id": "1"
+ }
+ },
+ "assignee": {
+ "data": {
+ "type": "people",
+ "id": "1"
+ }
+ },
+ "tags": {
+ "data": [
+ {
+ "type": "tags",
+ "id": "1"
+ },
+ {
+ "type": "tags",
+ "id": "2"
+ }
+ ]
+ }
+ }
+ }
+}
diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/ExampleApiClient.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/ExampleApiClient.cs
index 15ea897045..4a9cbd037e 100644
--- a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/ExampleApiClient.cs
+++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/ExampleApiClient.cs
@@ -10,6 +10,7 @@
using Microsoft.Kiota.Serialization.Multipart;
using Microsoft.Kiota.Serialization.Text;
using OpenApiKiotaClientExample.GeneratedCode.Api;
+using OpenApiKiotaClientExample.GeneratedCode.NonJsonApi;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
@@ -28,6 +29,12 @@ public partial class ExampleApiClient : BaseRequestBuilder
get => new global::OpenApiKiotaClientExample.GeneratedCode.Api.ApiRequestBuilder(PathParameters, RequestAdapter);
}
+ /// The NonJsonApi property
+ public global::OpenApiKiotaClientExample.GeneratedCode.NonJsonApi.NonJsonApiRequestBuilder NonJsonApi
+ {
+ get => new global::OpenApiKiotaClientExample.GeneratedCode.NonJsonApi.NonJsonApiRequestBuilder(PathParameters, RequestAdapter);
+ }
+
///
/// Instantiates a new and sets the default values.
///
diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/NonJsonApi/NonJsonApiRequestBuilder.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/NonJsonApi/NonJsonApiRequestBuilder.cs
new file mode 100644
index 0000000000..8aa2eb0d39
--- /dev/null
+++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/NonJsonApi/NonJsonApiRequestBuilder.cs
@@ -0,0 +1,206 @@
+//
+#nullable enable
+#pragma warning disable CS8625
+#pragma warning disable CS0618
+using Microsoft.Kiota.Abstractions.Extensions;
+using Microsoft.Kiota.Abstractions.Serialization;
+using Microsoft.Kiota.Abstractions;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading.Tasks;
+using System.Threading;
+using System;
+namespace OpenApiKiotaClientExample.GeneratedCode.NonJsonApi
+{
+ ///
+ /// Builds and executes requests for operations under \NonJsonApi
+ ///
+ [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")]
+ public partial class NonJsonApiRequestBuilder : BaseRequestBuilder
+ {
+ ///
+ /// Instantiates a new and sets the default values.
+ ///
+ /// Path parameters for the request
+ /// The request adapter to use to execute the requests.
+ public NonJsonApiRequestBuilder(Dictionary pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/NonJsonApi{?name*}", pathParameters)
+ {
+ }
+
+ ///
+ /// Instantiates a new and sets the default values.
+ ///
+ /// The raw URL to use for the request builder.
+ /// The request adapter to use to execute the requests.
+ public NonJsonApiRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/NonJsonApi{?name*}", rawUrl)
+ {
+ }
+ /// A
+ /// Cancellation token to use when cancelling requests
+ /// Configuration for the request such as headers, query parameters, and middleware options.
+ public async Task DeleteAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default)
+ {
+ var requestInfo = ToDeleteRequestInformation(requestConfiguration);
+ return await RequestAdapter.SendPrimitiveAsync(requestInfo, default, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// Returns a single-element JSON array.
+ ///
+ /// A List<string>
+ /// Cancellation token to use when cancelling requests
+ /// Configuration for the request such as headers, query parameters, and middleware options.
+ public async Task?> GetAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default)
+ {
+ var requestInfo = ToGetRequestInformation(requestConfiguration);
+ var collectionResult = await RequestAdapter.SendPrimitiveCollectionAsync(requestInfo, default, cancellationToken).ConfigureAwait(false);
+ return collectionResult?.AsList();
+ }
+
+ ///
+ /// Returns a single-element JSON array.
+ ///
+ /// A
+ /// Cancellation token to use when cancelling requests
+ /// Configuration for the request such as headers, query parameters, and middleware options.
+ public async Task HeadAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default)
+ {
+ var requestInfo = ToHeadRequestInformation(requestConfiguration);
+ return await RequestAdapter.SendPrimitiveAsync(requestInfo, default, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// Wishes you a good day.
+ ///
+ /// A
+ /// Cancellation token to use when cancelling requests
+ /// Configuration for the request such as headers, query parameters, and middleware options.
+ public async Task PatchAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default)
+ {
+ var requestInfo = ToPatchRequestInformation(requestConfiguration);
+ return await RequestAdapter.SendPrimitiveAsync(requestInfo, default, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// Returns a greeting text, based on your name.
+ ///
+ /// A
+ /// The request body
+ /// Cancellation token to use when cancelling requests
+ /// Configuration for the request such as headers, query parameters, and middleware options.
+ public async Task PostAsync(string body, Action>? requestConfiguration = default, CancellationToken cancellationToken = default)
+ {
+ if(string.IsNullOrEmpty(body)) throw new ArgumentNullException(nameof(body));
+ var requestInfo = ToPostRequestInformation(body, requestConfiguration);
+ return await RequestAdapter.SendPrimitiveAsync(requestInfo, default, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// Returns another greeting text.
+ ///
+ /// A
+ /// Cancellation token to use when cancelling requests
+ /// Configuration for the request such as headers, query parameters, and middleware options.
+ public async Task PutAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default)
+ {
+ var requestInfo = ToPutRequestInformation(requestConfiguration);
+ return await RequestAdapter.SendPrimitiveAsync(requestInfo, default, cancellationToken).ConfigureAwait(false);
+ }
+ /// A
+ /// Configuration for the request such as headers, query parameters, and middleware options.
+ public RequestInformation ToDeleteRequestInformation(Action>? requestConfiguration = default)
+ {
+ var requestInfo = new RequestInformation(Method.DELETE, UrlTemplate, PathParameters);
+ requestInfo.Configure(requestConfiguration);
+ return requestInfo;
+ }
+
+ ///
+ /// Returns a single-element JSON array.
+ ///
+ /// A
+ /// Configuration for the request such as headers, query parameters, and middleware options.
+ public RequestInformation ToGetRequestInformation(Action>? requestConfiguration = default)
+ {
+ var requestInfo = new RequestInformation(Method.GET, UrlTemplate, PathParameters);
+ requestInfo.Configure(requestConfiguration);
+ requestInfo.Headers.TryAdd("Accept", "application/json");
+ return requestInfo;
+ }
+
+ ///
+ /// Returns a single-element JSON array.
+ ///
+ /// A
+ /// Configuration for the request such as headers, query parameters, and middleware options.
+ public RequestInformation ToHeadRequestInformation(Action>? requestConfiguration = default)
+ {
+ var requestInfo = new RequestInformation(Method.HEAD, UrlTemplate, PathParameters);
+ requestInfo.Configure(requestConfiguration);
+ return requestInfo;
+ }
+
+ ///
+ /// Wishes you a good day.
+ ///
+ /// A
+ /// Configuration for the request such as headers, query parameters, and middleware options.
+ public RequestInformation ToPatchRequestInformation(Action>? requestConfiguration = default)
+ {
+ var requestInfo = new RequestInformation(Method.PATCH, UrlTemplate, PathParameters);
+ requestInfo.Configure(requestConfiguration);
+ requestInfo.Headers.TryAdd("Accept", "text/plain;q=0.9");
+ return requestInfo;
+ }
+
+ ///
+ /// Returns a greeting text, based on your name.
+ ///
+ /// A
+ /// The request body
+ /// Configuration for the request such as headers, query parameters, and middleware options.
+ public RequestInformation ToPostRequestInformation(string body, Action>? requestConfiguration = default)
+ {
+ if(string.IsNullOrEmpty(body)) throw new ArgumentNullException(nameof(body));
+ var requestInfo = new RequestInformation(Method.POST, UrlTemplate, PathParameters);
+ requestInfo.Configure(requestConfiguration);
+ requestInfo.Headers.TryAdd("Accept", "text/plain;q=0.9");
+ requestInfo.SetContentFromScalar(RequestAdapter, "application/json", body);
+ return requestInfo;
+ }
+
+ ///
+ /// Returns another greeting text.
+ ///
+ /// A
+ /// Configuration for the request such as headers, query parameters, and middleware options.
+ public RequestInformation ToPutRequestInformation(Action>? requestConfiguration = default)
+ {
+ var requestInfo = new RequestInformation(Method.PUT, UrlTemplate, PathParameters);
+ requestInfo.Configure(requestConfiguration);
+ requestInfo.Headers.TryAdd("Accept", "text/plain;q=0.9");
+ return requestInfo;
+ }
+
+ ///
+ /// Returns a request builder with the provided arbitrary URL. Using this method means any other path or query parameters are ignored.
+ ///
+ /// A
+ /// The raw URL to use for the request builder.
+ public global::OpenApiKiotaClientExample.GeneratedCode.NonJsonApi.NonJsonApiRequestBuilder WithUrl(string rawUrl)
+ {
+ return new global::OpenApiKiotaClientExample.GeneratedCode.NonJsonApi.NonJsonApiRequestBuilder(rawUrl, RequestAdapter);
+ }
+
+ ///
+ /// Returns another greeting text.
+ ///
+ [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")]
+ public partial class NonJsonApiRequestBuilderPutQueryParameters
+ {
+ [QueryParameter("name")]
+ public string? Name { get; set; }
+ }
+ }
+}
+#pragma warning restore CS0618
diff --git a/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs b/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs
index 3cda945d74..4c0cd133f9 100644
--- a/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs
+++ b/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs
@@ -173,7 +173,6 @@ public AttrAttribute GetAttributeByPublicName(string publicName)
public AttrAttribute GetAttributeByPropertyName(string propertyName)
{
AttrAttribute? attribute = FindAttributeByPropertyName(propertyName);
-
return attribute ?? throw new InvalidOperationException($"Attribute for property '{propertyName}' does not exist on resource type '{ClrType.Name}'.");
}
diff --git a/src/JsonApiDotNetCore.Annotations/JsonApiDotNetCore.Annotations.csproj b/src/JsonApiDotNetCore.Annotations/JsonApiDotNetCore.Annotations.csproj
index ed36e0797c..d615476081 100644
--- a/src/JsonApiDotNetCore.Annotations/JsonApiDotNetCore.Annotations.csproj
+++ b/src/JsonApiDotNetCore.Annotations/JsonApiDotNetCore.Annotations.csproj
@@ -46,5 +46,7 @@
+
diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.cs
index a906f4a667..7eb521aad8 100644
--- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.cs
+++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.cs
@@ -50,6 +50,18 @@ public HasManyCapabilities Capabilities
set => _capabilities = value;
}
+ ///
+ /// When set to true, overrules the default page size, the page size from a resource definition, and the
+ ///
+ /// page[size]
+ ///
+ /// query string parameter by forcibly turning off pagination on the related resources for this relationship.
+ ///
+ ///
+ /// Caution: only use this when the number of related resources (along with their nested includes) is known to always be small.
+ ///
+ public bool DisablePagination { get; set; }
+
public HasManyAttribute()
{
_lazyIsManyToMany = new Lazy(EvaluateIsManyToMany, LazyThreadSafetyMode.PublicationOnly);
diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.netstandard.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.netstandard.cs
index cf83f0ce17..9defe8d1d8 100644
--- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.netstandard.cs
+++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.netstandard.cs
@@ -11,4 +11,7 @@ public sealed class HasManyAttribute : RelationshipAttribute
{
///
public HasManyCapabilities Capabilities { get; set; }
+
+ ///
+ public bool DisablePagination { get; set; }
}
diff --git a/src/JsonApiDotNetCore.OpenApi.Client.NSwag/ApiException.cs b/src/JsonApiDotNetCore.OpenApi.Client.NSwag/ApiException.cs
index 8b66839e9e..a86118c875 100644
--- a/src/JsonApiDotNetCore.OpenApi.Client.NSwag/ApiException.cs
+++ b/src/JsonApiDotNetCore.OpenApi.Client.NSwag/ApiException.cs
@@ -5,6 +5,13 @@
namespace JsonApiDotNetCore.OpenApi.Client.NSwag;
+///
+/// Replacement for the auto-generated
+///
+/// ApiException
+///
+/// class from NSwag.
+///
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
public class ApiException(string message, int statusCode, string? response, IReadOnlyDictionary> headers, Exception? innerException)
: Exception($"HTTP {statusCode}: {message}", innerException)
@@ -14,6 +21,13 @@ public class ApiException(string message, int statusCode, string? response, IRea
public IReadOnlyDictionary> Headers { get; } = headers;
}
+///
+/// Replacement for the auto-generated
+///
+/// ApiException<TResult>
+///
+/// class from NSwag.
+///
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
public sealed class ApiException(
string message, int statusCode, string? response, IReadOnlyDictionary> headers, TResult result, Exception? innerException)
diff --git a/src/JsonApiDotNetCore.OpenApi.Client.NSwag/ApiResponse.cs b/src/JsonApiDotNetCore.OpenApi.Client.NSwag/ApiResponse.cs
index 74ee77127f..7d3d7c2a52 100644
--- a/src/JsonApiDotNetCore.OpenApi.Client.NSwag/ApiResponse.cs
+++ b/src/JsonApiDotNetCore.OpenApi.Client.NSwag/ApiResponse.cs
@@ -3,6 +3,13 @@
namespace JsonApiDotNetCore.OpenApi.Client.NSwag;
+///
+/// Replacement for the auto-generated
+///
+/// SwaggerResponse
+///
+/// class from NSwag.
+///
[PublicAPI]
public class ApiResponse(int statusCode, IReadOnlyDictionary> headers)
{
@@ -71,6 +78,13 @@ public static async Task TranslateAsync(Func> ope
}
}
+///
+/// Replacement for the auto-generated
+///
+/// SwaggerResponse<TResult>
+///
+/// class from NSwag.
+///
[PublicAPI]
public class ApiResponse(int statusCode, IReadOnlyDictionary> headers, TResult result)
: ApiResponse(statusCode, headers)
diff --git a/src/JsonApiDotNetCore.OpenApi.Client.NSwag/BlockedJsonInheritanceConverter.cs b/src/JsonApiDotNetCore.OpenApi.Client.NSwag/BlockedJsonInheritanceConverter.cs
index a3a7e627db..602bacce63 100644
--- a/src/JsonApiDotNetCore.OpenApi.Client.NSwag/BlockedJsonInheritanceConverter.cs
+++ b/src/JsonApiDotNetCore.OpenApi.Client.NSwag/BlockedJsonInheritanceConverter.cs
@@ -4,6 +4,13 @@
namespace JsonApiDotNetCore.OpenApi.Client.NSwag;
// Referenced from liquid template, to ensure the built-in JsonInheritanceConverter from NSwag is never used.
+///
+/// Exists to block usage of the default
+///
+/// JsonInheritanceConverter
+///
+/// from NSwag, which is incompatible with JSON:API.
+///
[PublicAPI]
public abstract class BlockedJsonInheritanceConverter : JsonConverter
{
@@ -31,11 +38,17 @@ public override bool CanConvert(Type objectType)
return true;
}
+ ///
+ /// Always throws an .
+ ///
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
{
throw new InvalidOperationException("JsonInheritanceConverter is incompatible with JSON:API and must not be used.");
}
+ ///
+ /// Always throws an .
+ ///
public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
{
throw new InvalidOperationException("JsonInheritanceConverter is incompatible with JSON:API and must not be used.");
diff --git a/src/JsonApiDotNetCore.OpenApi.Client.NSwag/IJsonApiClient.cs b/src/JsonApiDotNetCore.OpenApi.Client.NSwag/IJsonApiClient.cs
deleted file mode 100644
index df6a35d78e..0000000000
--- a/src/JsonApiDotNetCore.OpenApi.Client.NSwag/IJsonApiClient.cs
+++ /dev/null
@@ -1,41 +0,0 @@
-using System.Linq.Expressions;
-using JetBrains.Annotations;
-
-namespace JsonApiDotNetCore.OpenApi.Client.NSwag;
-
-[PublicAPI]
-public interface IJsonApiClient
-{
- ///
- /// Ensures correct serialization of JSON:API attributes in the request body of a POST/PATCH request at a resource endpoint. Properties with default
- /// values are omitted, unless explicitly included using
- ///
- /// In JSON:API, an omitted attribute indicates to ignore it, while an attribute that is set to null means to clear it. This poses a problem,
- /// because the serializer cannot distinguish between "you have explicitly set this .NET property to its default value" vs "you didn't touch it, so it
- /// contains its default value" when converting to JSON.
- ///
- ///
- ///
- /// The request document instance for which default values should be omitted.
- ///
- ///
- /// Optional. A list of lambda expressions that indicate which properties to always include in the JSON request body. For example:
- /// video.Title, video => video.Summary
- /// ]]>
- ///
- ///
- /// The type of the request document.
- ///
- ///
- /// The type of the attributes object inside .
- ///
- ///
- /// An to clear the current registration. For efficient memory usage, it is recommended to wrap calls to this method in a
- /// using statement, so the registrations are cleaned up after executing the request. After disposal, the client can be reused without the
- /// registrations added earlier.
- ///
- IDisposable WithPartialAttributeSerialization(TRequestDocument requestDocument,
- params Expression>[] alwaysIncludedAttributeSelectors)
- where TRequestDocument : class;
-}
diff --git a/src/JsonApiDotNetCore.OpenApi.Client.NSwag/NotifyPropertySet.cs b/src/JsonApiDotNetCore.OpenApi.Client.NSwag/NotifyPropertySet.cs
index 80adb56477..111b894879 100644
--- a/src/JsonApiDotNetCore.OpenApi.Client.NSwag/NotifyPropertySet.cs
+++ b/src/JsonApiDotNetCore.OpenApi.Client.NSwag/NotifyPropertySet.cs
@@ -11,7 +11,8 @@
namespace JsonApiDotNetCore.OpenApi.Client.NSwag;
///
-/// Implementation of that doesn't detect changes.
+/// Implementation of that unconditionally raises the event when a property is
+/// assigned. Exists to support JSON:API partial POST/PATCH.
///
[PublicAPI]
public abstract class NotifyPropertySet : INotifyPropertyChanged
diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ActionDescriptorExtensions.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ActionDescriptorExtensions.cs
index 1892aca576..6993d10cdf 100644
--- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ActionDescriptorExtensions.cs
+++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ActionDescriptorExtensions.cs
@@ -1,7 +1,6 @@
using System.Reflection;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Controllers;
-using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.ModelBinding;
namespace JsonApiDotNetCore.OpenApi.Swashbuckle;
@@ -12,22 +11,32 @@ public static MethodInfo GetActionMethod(this ActionDescriptor descriptor)
{
ArgumentNullException.ThrowIfNull(descriptor);
- return ((ControllerActionDescriptor)descriptor).MethodInfo;
- }
+ if (descriptor is ControllerActionDescriptor controllerActionDescriptor)
+ {
+ return controllerActionDescriptor.MethodInfo;
+ }
- public static TFilterMetaData? GetFilterMetadata(this ActionDescriptor descriptor)
- where TFilterMetaData : IFilterMetadata
- {
- ArgumentNullException.ThrowIfNull(descriptor);
+ MethodInfo? methodInfo = descriptor.EndpointMetadata.OfType().FirstOrDefault();
+ ConsistencyGuard.ThrowIf(methodInfo == null);
- return descriptor.FilterDescriptors.Select(filterDescriptor => filterDescriptor.Filter).OfType().FirstOrDefault();
+ return methodInfo;
}
public static ControllerParameterDescriptor? GetBodyParameterDescriptor(this ActionDescriptor descriptor)
{
ArgumentNullException.ThrowIfNull(descriptor);
- return (ControllerParameterDescriptor?)descriptor.Parameters.FirstOrDefault(parameterDescriptor =>
+ ParameterDescriptor? parameterDescriptor = descriptor.Parameters.FirstOrDefault(parameterDescriptor =>
parameterDescriptor.BindingInfo?.BindingSource == BindingSource.Body);
+
+ if (parameterDescriptor != null)
+ {
+ var controllerParameterDescriptor = parameterDescriptor as ControllerParameterDescriptor;
+ ConsistencyGuard.ThrowIf(controllerParameterDescriptor == null);
+
+ return controllerParameterDescriptor;
+ }
+
+ return null;
}
}
diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ConfigureMvcOptions.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ConfigureMvcOptions.cs
index e1e04a2464..7800e75735 100644
--- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ConfigureMvcOptions.cs
+++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ConfigureMvcOptions.cs
@@ -1,50 +1,29 @@
using JsonApiDotNetCore.Configuration;
-using JsonApiDotNetCore.Middleware;
using Microsoft.AspNetCore.Mvc;
-using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace JsonApiDotNetCore.OpenApi.Swashbuckle;
internal sealed class ConfigureMvcOptions : IConfigureOptions
{
- private readonly IJsonApiRoutingConvention _jsonApiRoutingConvention;
- private readonly OpenApiEndpointConvention _openApiEndpointConvention;
private readonly JsonApiRequestFormatMetadataProvider _jsonApiRequestFormatMetadataProvider;
- private readonly IJsonApiOptions _jsonApiOptions;
+ private readonly JsonApiOptions _jsonApiOptions;
- public ConfigureMvcOptions(IJsonApiRoutingConvention jsonApiRoutingConvention, OpenApiEndpointConvention openApiEndpointConvention,
- JsonApiRequestFormatMetadataProvider jsonApiRequestFormatMetadataProvider, IJsonApiOptions jsonApiOptions)
+ public ConfigureMvcOptions(JsonApiRequestFormatMetadataProvider jsonApiRequestFormatMetadataProvider, IJsonApiOptions jsonApiOptions)
{
- ArgumentNullException.ThrowIfNull(jsonApiRoutingConvention);
- ArgumentNullException.ThrowIfNull(openApiEndpointConvention);
ArgumentNullException.ThrowIfNull(jsonApiRequestFormatMetadataProvider);
ArgumentNullException.ThrowIfNull(jsonApiOptions);
- _jsonApiRoutingConvention = jsonApiRoutingConvention;
- _openApiEndpointConvention = openApiEndpointConvention;
_jsonApiRequestFormatMetadataProvider = jsonApiRequestFormatMetadataProvider;
- _jsonApiOptions = jsonApiOptions;
+ _jsonApiOptions = (JsonApiOptions)jsonApiOptions;
}
public void Configure(MvcOptions options)
{
ArgumentNullException.ThrowIfNull(options);
- AddSwashbuckleCliCompatibility(options);
-
options.InputFormatters.Add(_jsonApiRequestFormatMetadataProvider);
- options.Conventions.Add(_openApiEndpointConvention);
-
- ((JsonApiOptions)_jsonApiOptions).IncludeExtensions(OpenApiMediaTypeExtension.OpenApi, OpenApiMediaTypeExtension.RelaxedOpenApi);
- }
- private void AddSwashbuckleCliCompatibility(MvcOptions options)
- {
- if (!options.Conventions.Any(convention => convention is IJsonApiRoutingConvention))
- {
- // See https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/1957 for why this is needed.
- options.Conventions.Insert(0, _jsonApiRoutingConvention);
- }
+ _jsonApiOptions.IncludeExtensions(OpenApiMediaTypeExtension.OpenApi, OpenApiMediaTypeExtension.RelaxedOpenApi);
}
}
diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ConfigureSwaggerGenOptions.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ConfigureSwaggerGenOptions.cs
index f3fb5198ca..efef31c7e4 100644
--- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ConfigureSwaggerGenOptions.cs
+++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ConfigureSwaggerGenOptions.cs
@@ -1,6 +1,6 @@
-using System.Reflection;
using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCore.Middleware;
+using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata.ActionMethods;
using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.AtomicOperations;
using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects;
using JsonApiDotNetCore.OpenApi.Swashbuckle.SwaggerComponents;
@@ -33,6 +33,8 @@ internal sealed class ConfigureSwaggerGenOptions : IConfigureOptions)
];
+ private static readonly Func> DefaultTagsSelector = new SwaggerGeneratorOptions().TagsSelector;
+
private readonly OpenApiOperationIdSelector _operationIdSelector;
private readonly JsonApiSchemaIdSelector _schemaIdSelector;
private readonly IControllerResourceMapping _controllerResourceMapping;
@@ -142,11 +144,27 @@ private static void IncludeDerivedTypes(ResourceType baseType, List clrTyp
}
}
- private static List GetOpenApiOperationTags(ApiDescription description, IControllerResourceMapping controllerResourceMapping)
+ private static IList GetOpenApiOperationTags(ApiDescription description, IControllerResourceMapping controllerResourceMapping)
{
- MethodInfo actionMethod = description.ActionDescriptor.GetActionMethod();
- ResourceType? resourceType = controllerResourceMapping.GetResourceTypeForController(actionMethod.ReflectedType);
+ var actionMethod = OpenApiActionMethod.Create(description.ActionDescriptor);
+
+ switch (actionMethod)
+ {
+ case AtomicOperationsActionMethod:
+ {
+ return ["operations"];
+ }
+ case JsonApiActionMethod jsonApiActionMethod:
+ {
+ ResourceType? resourceType = controllerResourceMapping.GetResourceTypeForController(jsonApiActionMethod.ControllerType);
+ ConsistencyGuard.ThrowIf(resourceType == null);
- return resourceType == null ? ["operations"] : [resourceType.PublicName];
+ return [resourceType.PublicName];
+ }
+ default:
+ {
+ return DefaultTagsSelector(description);
+ }
+ }
}
}
diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiActionDescriptorCollectionProvider.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiActionDescriptorCollectionProvider.cs
index 6d63a540cd..5d05490e8d 100644
--- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiActionDescriptorCollectionProvider.cs
+++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiActionDescriptorCollectionProvider.cs
@@ -1,194 +1,333 @@
+using System.Collections.Concurrent;
+using System.Net;
using System.Reflection;
+using JsonApiDotNetCore.Configuration;
+using JsonApiDotNetCore.Controllers;
using JsonApiDotNetCore.Errors;
using JsonApiDotNetCore.Middleware;
using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata;
+using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata.ActionMethods;
+using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata.Documents;
using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Documents;
+using JsonApiDotNetCore.Resources.Annotations;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.Mvc.Routing;
-using Microsoft.Net.Http.Headers;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.Logging;
namespace JsonApiDotNetCore.OpenApi.Swashbuckle;
///
-/// Adds JsonApiDotNetCore metadata to s if available. This translates to updating response types in
-/// and performing an expansion for secondary and relationship endpoints. For example:
-/// s and performs endpoint expansion for secondary and relationship
+/// endpoints. For example: /article/{id}/author, /article/{id}/revisions, etc.
/// ]]>
///
-internal sealed class JsonApiActionDescriptorCollectionProvider : IActionDescriptorCollectionProvider
+internal sealed partial class JsonApiActionDescriptorCollectionProvider : IActionDescriptorCollectionProvider
{
- private static readonly string DefaultMediaType = JsonApiMediaType.Default.ToString();
+ private const int FilterScope = 10;
+ private static readonly Type ErrorDocumentType = typeof(ErrorResponseDocument);
private readonly IActionDescriptorCollectionProvider _defaultProvider;
+ private readonly IControllerResourceMapping _controllerResourceMapping;
private readonly JsonApiEndpointMetadataProvider _jsonApiEndpointMetadataProvider;
+ private readonly IJsonApiOptions _options;
+ private readonly ILogger _logger;
+ private readonly ConcurrentDictionary> _versionedActionDescriptorCache = new();
- public ActionDescriptorCollection ActionDescriptors => GetActionDescriptors();
+ public ActionDescriptorCollection ActionDescriptors =>
+ _versionedActionDescriptorCache.GetOrAdd(_defaultProvider.ActionDescriptors.Version, LazyGetActionDescriptors).Value;
- public JsonApiActionDescriptorCollectionProvider(IActionDescriptorCollectionProvider defaultProvider,
- JsonApiEndpointMetadataProvider jsonApiEndpointMetadataProvider)
+ public JsonApiActionDescriptorCollectionProvider(IActionDescriptorCollectionProvider defaultProvider, IControllerResourceMapping controllerResourceMapping,
+ JsonApiEndpointMetadataProvider jsonApiEndpointMetadataProvider, IJsonApiOptions options, ILogger logger)
{
ArgumentNullException.ThrowIfNull(defaultProvider);
+ ArgumentNullException.ThrowIfNull(controllerResourceMapping);
ArgumentNullException.ThrowIfNull(jsonApiEndpointMetadataProvider);
+ ArgumentNullException.ThrowIfNull(options);
+ ArgumentNullException.ThrowIfNull(logger);
_defaultProvider = defaultProvider;
+ _controllerResourceMapping = controllerResourceMapping;
_jsonApiEndpointMetadataProvider = jsonApiEndpointMetadataProvider;
+ _options = options;
+ _logger = logger;
}
- private ActionDescriptorCollection GetActionDescriptors()
+ private Lazy LazyGetActionDescriptors(int version)
{
- List newDescriptors = _defaultProvider.ActionDescriptors.Items.ToList();
- ActionDescriptor[] endpoints = newDescriptors.Where(IsVisibleJsonApiEndpoint).ToArray();
+ // https://andrewlock.net/making-getoradd-on-concurrentdictionary-thread-safe-using-lazy/
+ return new Lazy(() => GetActionDescriptors(version), LazyThreadSafetyMode.ExecutionAndPublication);
+ }
+
+ private ActionDescriptorCollection GetActionDescriptors(int version)
+ {
+ List descriptors = [];
- foreach (ActionDescriptor endpoint in endpoints)
+ foreach (ActionDescriptor descriptor in _defaultProvider.ActionDescriptors.Items)
{
- MethodInfo actionMethod = endpoint.GetActionMethod();
- JsonApiEndpointMetadataContainer endpointMetadataContainer = _jsonApiEndpointMetadataProvider.Get(actionMethod);
+ if (!descriptor.EndpointMetadata.OfType().SelectMany(metadata => metadata.HttpMethods).Any())
+ {
+ // Technically incorrect: when no verbs, the endpoint is exposed on all verbs. But Swashbuckle hides it anyway.
+ continue;
+ }
- List replacementDescriptorsForEndpoint =
- [
- .. AddJsonApiMetadataToAction(endpoint, endpointMetadataContainer.RequestMetadata),
- .. AddJsonApiMetadataToAction(endpoint, endpointMetadataContainer.ResponseMetadata)
- ];
+ var actionMethod = OpenApiActionMethod.Create(descriptor);
+
+ if (actionMethod is CustomJsonApiActionMethod)
+ {
+ // A non-standard action method in a JSON:API controller. Not yet implemented, so skip to prevent downstream crashes.
+ string httpMethods = string.Join(", ", descriptor.EndpointMetadata.OfType().SelectMany(metadata => metadata.HttpMethods));
+ LogSuppressedActionMethod(httpMethods, descriptor.DisplayName);
+
+ continue;
+ }
- if (replacementDescriptorsForEndpoint.Count > 0)
+ if (actionMethod is BuiltinJsonApiActionMethod builtinActionMethod)
{
- newDescriptors.InsertRange(newDescriptors.IndexOf(endpoint), replacementDescriptorsForEndpoint);
- newDescriptors.Remove(endpoint);
+ if (!IsVisibleEndpoint(descriptor))
+ {
+ continue;
+ }
+
+ ResourceType? resourceType = _controllerResourceMapping.GetResourceTypeForController(builtinActionMethod.ControllerType);
+
+ if (builtinActionMethod is JsonApiActionMethod jsonApiActionMethod)
+ {
+ ConsistencyGuard.ThrowIf(resourceType == null);
+
+ if (ShouldSuppressEndpoint(jsonApiActionMethod.Endpoint, resourceType))
+ {
+ continue;
+ }
+ }
+
+ ActionDescriptor[] replacementDescriptors = SetEndpointMetadata(descriptor, builtinActionMethod, resourceType);
+ descriptors.AddRange(replacementDescriptors);
+
+ continue;
}
+
+ descriptors.Add(descriptor);
}
- int descriptorVersion = _defaultProvider.ActionDescriptors.Version;
- return new ActionDescriptorCollection(newDescriptors.AsReadOnly(), descriptorVersion);
+ return new ActionDescriptorCollection(descriptors.AsReadOnly(), version);
}
- internal static bool IsVisibleJsonApiEndpoint(ActionDescriptor descriptor)
+ internal static bool IsVisibleEndpoint(ActionDescriptor descriptor)
{
// Only if in a convention ApiExplorer.IsVisible was set to false, the ApiDescriptionActionData will not be present.
- return descriptor is ControllerActionDescriptor controllerAction && controllerAction.Properties.ContainsKey(typeof(ApiDescriptionActionData));
+ return descriptor is ControllerActionDescriptor controllerDescriptor && controllerDescriptor.Properties.ContainsKey(typeof(ApiDescriptionActionData));
}
- private static List AddJsonApiMetadataToAction(ActionDescriptor endpoint, IJsonApiEndpointMetadata? jsonApiEndpointMetadata)
+ private static bool ShouldSuppressEndpoint(JsonApiEndpoints endpoint, ResourceType resourceType)
{
- switch (jsonApiEndpointMetadata)
+ if (!IsEndpointAvailable(endpoint, resourceType))
{
- case PrimaryResponseMetadata primaryMetadata:
- {
- UpdateProducesResponseTypeAttribute(endpoint, primaryMetadata.DocumentType);
- return [];
- }
- case PrimaryRequestMetadata primaryMetadata:
- {
- UpdateBodyParameterDescriptor(endpoint, primaryMetadata.DocumentType, null);
- return [];
- }
- case NonPrimaryEndpointMetadata nonPrimaryEndpointMetadata and (RelationshipResponseMetadata or SecondaryResponseMetadata):
- {
- return Expand(endpoint, nonPrimaryEndpointMetadata,
- (expandedEndpoint, documentType, _) => UpdateProducesResponseTypeAttribute(expandedEndpoint, documentType));
- }
- case NonPrimaryEndpointMetadata nonPrimaryEndpointMetadata and RelationshipRequestMetadata:
- {
- return Expand(endpoint, nonPrimaryEndpointMetadata, UpdateBodyParameterDescriptor);
- }
- case AtomicOperationsRequestMetadata:
- {
- UpdateBodyParameterDescriptor(endpoint, typeof(OperationsRequestDocument), null);
- return [];
- }
- case AtomicOperationsResponseMetadata:
+ return true;
+ }
+
+ if (IsSecondaryOrRelationshipEndpoint(endpoint))
+ {
+ if (resourceType.Relationships.Count == 0)
{
- UpdateProducesResponseTypeAttribute(endpoint, typeof(OperationsResponseDocument));
- return [];
+ return true;
}
- default:
+
+ if (endpoint is JsonApiEndpoints.DeleteRelationship or JsonApiEndpoints.PostRelationship)
{
- return [];
+ return !resourceType.Relationships.OfType().Any();
}
}
+
+ return false;
}
- private static void UpdateProducesResponseTypeAttribute(ActionDescriptor endpoint, Type responseDocumentType)
+ private static bool IsEndpointAvailable(JsonApiEndpoints endpoint, ResourceType resourceType)
{
- ProducesResponseTypeAttribute? attribute = null;
+ JsonApiEndpoints availableEndpoints = GetGeneratedControllerEndpoints(resourceType);
- if (ProducesJsonApiResponseDocument(endpoint))
+ if (availableEndpoints == JsonApiEndpoints.None)
{
- var producesResponse = endpoint.GetFilterMetadata();
-
- if (producesResponse != null)
- {
- attribute = producesResponse;
- }
+ // Auto-generated controllers are disabled, so we can't know what to hide.
+ // It is assumed that a handwritten JSON:API controller only provides action methods for what it supports.
+ // To accomplish that, derive from BaseJsonApiController instead of JsonApiController.
+ return true;
}
- ConsistencyGuard.ThrowIf(attribute == null);
- attribute.Type = responseDocumentType;
+ // For an overridden JSON:API action method in a partial class to show up, it's flag must be turned on in [Resource].
+ // Otherwise, it is considered to be an action method that throws because the endpoint is unavailable.
+ return IncludesEndpoint(endpoint, availableEndpoints);
+ }
+
+ private static JsonApiEndpoints GetGeneratedControllerEndpoints(ResourceType resourceType)
+ {
+ var resourceAttribute = resourceType.ClrType.GetCustomAttribute();
+ return resourceAttribute?.GenerateControllerEndpoints ?? JsonApiEndpoints.None;
}
- private static bool ProducesJsonApiResponseDocument(ActionDescriptor endpoint)
+ private static bool IncludesEndpoint(JsonApiEndpoints endpoint, JsonApiEndpoints availableEndpoints)
{
- var produces = endpoint.GetFilterMetadata();
+ bool? isIncluded = null;
- if (produces != null)
+ if (endpoint == JsonApiEndpoints.GetCollection)
{
- foreach (string contentType in produces.ContentTypes)
- {
- if (MediaTypeHeaderValue.TryParse(contentType, out MediaTypeHeaderValue? headerValue))
- {
- if (headerValue.MediaType.Equals(DefaultMediaType, StringComparison.OrdinalIgnoreCase))
- {
- return true;
- }
- }
- }
+ isIncluded = availableEndpoints.HasFlag(JsonApiEndpoints.GetCollection);
+ }
+ else if (endpoint == JsonApiEndpoints.GetSingle)
+ {
+ isIncluded = availableEndpoints.HasFlag(JsonApiEndpoints.GetSingle);
+ }
+ else if (endpoint == JsonApiEndpoints.GetSecondary)
+ {
+ isIncluded = availableEndpoints.HasFlag(JsonApiEndpoints.GetSecondary);
+ }
+ else if (endpoint == JsonApiEndpoints.GetRelationship)
+ {
+ isIncluded = availableEndpoints.HasFlag(JsonApiEndpoints.GetRelationship);
+ }
+ else if (endpoint == JsonApiEndpoints.Post)
+ {
+ isIncluded = availableEndpoints.HasFlag(JsonApiEndpoints.Post);
+ }
+ else if (endpoint == JsonApiEndpoints.PostRelationship)
+ {
+ isIncluded = availableEndpoints.HasFlag(JsonApiEndpoints.PostRelationship);
+ }
+ else if (endpoint == JsonApiEndpoints.Patch)
+ {
+ isIncluded = availableEndpoints.HasFlag(JsonApiEndpoints.Patch);
+ }
+ else if (endpoint == JsonApiEndpoints.PatchRelationship)
+ {
+ isIncluded = availableEndpoints.HasFlag(JsonApiEndpoints.PatchRelationship);
+ }
+ else if (endpoint == JsonApiEndpoints.Delete)
+ {
+ isIncluded = availableEndpoints.HasFlag(JsonApiEndpoints.Delete);
+ }
+ else if (endpoint == JsonApiEndpoints.DeleteRelationship)
+ {
+ isIncluded = availableEndpoints.HasFlag(JsonApiEndpoints.DeleteRelationship);
}
- return false;
+ ConsistencyGuard.ThrowIf(isIncluded == null);
+ return isIncluded.Value;
}
- private static List Expand(ActionDescriptor genericEndpoint, NonPrimaryEndpointMetadata metadata,
- Action expansionCallback)
+ private static bool IsSecondaryOrRelationshipEndpoint(JsonApiEndpoints endpoint)
{
- List expansion = [];
+ return endpoint is JsonApiEndpoints.GetSecondary or JsonApiEndpoints.GetRelationship or JsonApiEndpoints.PostRelationship or
+ JsonApiEndpoints.PatchRelationship or JsonApiEndpoints.DeleteRelationship;
+ }
+
+ private ActionDescriptor[] SetEndpointMetadata(ActionDescriptor descriptor, BuiltinJsonApiActionMethod actionMethod, ResourceType? resourceType)
+ {
+ Dictionary descriptorsByRelationship = [];
- foreach ((string relationshipName, Type documentType) in metadata.DocumentTypesByRelationshipName)
+ JsonApiEndpointMetadata endpointMetadata = _jsonApiEndpointMetadataProvider.Get(descriptor);
+
+ switch (endpointMetadata.RequestMetadata)
{
- if (genericEndpoint.AttributeRouteInfo == null)
+ case AtomicOperationsRequestMetadata atomicOperationsRequestMetadata:
{
- throw new NotSupportedException("Only attribute routing is supported for JsonApiDotNetCore endpoints.");
+ SetConsumes(descriptor, atomicOperationsRequestMetadata.DocumentType, JsonApiMediaType.AtomicOperations);
+ UpdateRequestBodyParameterDescriptor(descriptor, atomicOperationsRequestMetadata.DocumentType, null);
+
+ break;
}
+ case PrimaryRequestMetadata primaryRequestMetadata:
+ {
+ SetConsumes(descriptor, primaryRequestMetadata.DocumentType, JsonApiMediaType.Default);
+ UpdateRequestBodyParameterDescriptor(descriptor, primaryRequestMetadata.DocumentType, null);
- ActionDescriptor expandedEndpoint = Clone(genericEndpoint);
+ break;
+ }
+ case RelationshipRequestMetadata relationshipRequestMetadata:
+ {
+ ConsistencyGuard.ThrowIf(descriptor.AttributeRouteInfo == null);
- RemovePathParameter(expandedEndpoint.Parameters, "relationshipName");
+ foreach ((RelationshipAttribute relationship, Type documentType) in relationshipRequestMetadata.DocumentTypesByRelationship)
+ {
+ ActionDescriptor relationshipDescriptor = Clone(descriptor);
- ExpandTemplate(expandedEndpoint.AttributeRouteInfo!, relationshipName);
+ RemovePathParameter(relationshipDescriptor.Parameters, "relationshipName");
+ ExpandTemplate(relationshipDescriptor.AttributeRouteInfo!, relationship.PublicName);
+ SetConsumes(descriptor, documentType, JsonApiMediaType.Default);
+ UpdateRequestBodyParameterDescriptor(relationshipDescriptor, documentType, relationship.PublicName);
- expansionCallback(expandedEndpoint, documentType, relationshipName);
+ descriptorsByRelationship[relationship] = relationshipDescriptor;
+ }
- expansion.Add(expandedEndpoint);
+ break;
+ }
+ }
+
+ switch (endpointMetadata.ResponseMetadata)
+ {
+ case AtomicOperationsResponseMetadata atomicOperationsResponseMetadata:
+ {
+ SetProduces(descriptor, atomicOperationsResponseMetadata.DocumentType);
+ SetProducesResponseTypes(descriptor, actionMethod, resourceType, atomicOperationsResponseMetadata.DocumentType);
+
+ break;
+ }
+ case PrimaryResponseMetadata primaryResponseMetadata:
+ {
+ SetProduces(descriptor, primaryResponseMetadata.DocumentType);
+ SetProducesResponseTypes(descriptor, actionMethod, resourceType, primaryResponseMetadata.DocumentType);
+ break;
+ }
+ case NonPrimaryResponseMetadata nonPrimaryResponseMetadata:
+ {
+ foreach ((RelationshipAttribute relationship, Type documentType) in nonPrimaryResponseMetadata.DocumentTypesByRelationship)
+ {
+ SetNonPrimaryResponseMetadata(descriptor, actionMethod, resourceType, descriptorsByRelationship, relationship, documentType);
+ }
+
+ break;
+ }
+ case EmptyRelationshipResponseMetadata emptyRelationshipResponseMetadata:
+ {
+ foreach (RelationshipAttribute relationship in emptyRelationshipResponseMetadata.Relationships)
+ {
+ SetNonPrimaryResponseMetadata(descriptor, actionMethod, resourceType, descriptorsByRelationship, relationship, null);
+ }
+
+ break;
+ }
}
- return expansion;
+ return descriptorsByRelationship.Count == 0 ? [descriptor] : descriptorsByRelationship.Values.ToArray();
+ }
+
+ private static void SetConsumes(ActionDescriptor descriptor, Type requestType, JsonApiMediaType mediaType)
+ {
+ // This value doesn't actually appear in the OpenAPI document, but is only used to invoke
+ // JsonApiRequestFormatMetadataProvider.GetSupportedContentTypes(), which determines the actual request content type.
+ string contentType = mediaType.ToString();
+
+ descriptor.FilterDescriptors.Add(new FilterDescriptor(new ConsumesAttribute(requestType, contentType), FilterScope));
}
- private static void UpdateBodyParameterDescriptor(ActionDescriptor endpoint, Type documentType, string? parameterName)
+ private static void UpdateRequestBodyParameterDescriptor(ActionDescriptor descriptor, Type documentType, string? parameterName)
{
- ControllerParameterDescriptor? requestBodyDescriptor = endpoint.GetBodyParameterDescriptor();
+ ControllerParameterDescriptor? requestBodyDescriptor = descriptor.GetBodyParameterDescriptor();
if (requestBodyDescriptor == null)
{
- MethodInfo actionMethod = endpoint.GetActionMethod();
+ MethodInfo actionMethod = descriptor.GetActionMethod();
throw new InvalidConfigurationException(
$"The action method '{actionMethod}' on type '{actionMethod.ReflectedType?.FullName}' contains no parameter with a [FromBody] attribute.");
}
+ descriptor.EndpointMetadata.Add(new ConsumesAttribute(JsonApiMediaType.Default.ToString()));
+
requestBodyDescriptor.ParameterType = documentType;
requestBodyDescriptor.ParameterInfo = new ParameterInfoWrapper(requestBodyDescriptor.ParameterInfo, documentType, parameterName);
}
@@ -218,8 +357,172 @@ private static void RemovePathParameter(ICollection paramet
parameters.Remove(descriptor);
}
- private static void ExpandTemplate(AttributeRouteInfo route, string expansionParameter)
+ private static void ExpandTemplate(AttributeRouteInfo route, string parameterName)
+ {
+ route.Template = route.Template!.Replace("{relationshipName}", parameterName);
+ }
+
+ private void SetProduces(ActionDescriptor descriptor, Type? documentType)
{
- route.Template = route.Template!.Replace("{relationshipName}", expansionParameter);
+ IReadOnlyList contentTypes = OpenApiContentTypeProvider.Instance.GetResponseContentTypes(documentType);
+
+ if (contentTypes.Count > 0)
+ {
+ descriptor.FilterDescriptors.Add(new FilterDescriptor(new ProducesAttribute(contentTypes[0]), FilterScope));
+ }
}
+
+ private void SetProducesResponseTypes(ActionDescriptor descriptor, BuiltinJsonApiActionMethod actionMethod, ResourceType? resourceType, Type? documentType)
+ {
+ foreach (HttpStatusCode statusCode in GetSuccessStatusCodesForActionMethod(actionMethod))
+ {
+ descriptor.FilterDescriptors.Add(documentType == null || StatusCodeHasNoResponseBody(statusCode)
+ ? new FilterDescriptor(new ProducesResponseTypeAttribute(typeof(void), (int)statusCode), FilterScope)
+ : new FilterDescriptor(new ProducesResponseTypeAttribute(documentType, (int)statusCode), FilterScope));
+ }
+
+ string? errorContentType = null;
+
+ if (documentType == null)
+ {
+ IReadOnlyList errorContentTypes = OpenApiContentTypeProvider.Instance.GetResponseContentTypes(ErrorDocumentType);
+ ConsistencyGuard.ThrowIf(errorContentTypes.Count == 0);
+ errorContentType = errorContentTypes[0];
+ }
+
+ foreach (HttpStatusCode statusCode in GetErrorStatusCodesForActionMethod(actionMethod, resourceType))
+ {
+ descriptor.FilterDescriptors.Add(errorContentType != null
+ ? new FilterDescriptor(new ProducesResponseTypeAttribute(ErrorDocumentType, (int)statusCode, errorContentType), FilterScope)
+ : new FilterDescriptor(new ProducesResponseTypeAttribute(ErrorDocumentType, (int)statusCode), FilterScope));
+ }
+ }
+
+ private static HttpStatusCode[] GetSuccessStatusCodesForActionMethod(BuiltinJsonApiActionMethod actionMethod)
+ {
+ HttpStatusCode[]? statusCodes = null;
+
+ if (actionMethod is AtomicOperationsActionMethod)
+ {
+ statusCodes =
+ [
+ HttpStatusCode.OK,
+ HttpStatusCode.NoContent
+ ];
+ }
+ else if (actionMethod is JsonApiActionMethod jsonApiActionMethod)
+ {
+ statusCodes = jsonApiActionMethod.Endpoint switch
+ {
+ JsonApiEndpoints.GetCollection or JsonApiEndpoints.GetSingle or JsonApiEndpoints.GetSecondary or JsonApiEndpoints.GetRelationship =>
+ [
+ HttpStatusCode.OK,
+ HttpStatusCode.NotModified
+ ],
+ JsonApiEndpoints.Post =>
+ [
+ HttpStatusCode.Created,
+ HttpStatusCode.NoContent
+ ],
+ JsonApiEndpoints.Patch =>
+ [
+ HttpStatusCode.OK,
+ HttpStatusCode.NoContent
+ ],
+ JsonApiEndpoints.Delete or JsonApiEndpoints.PostRelationship or JsonApiEndpoints.PatchRelationship or JsonApiEndpoints.DeleteRelationship =>
+ [
+ HttpStatusCode.NoContent
+ ],
+ _ => null
+ };
+ }
+
+ ConsistencyGuard.ThrowIf(statusCodes == null);
+ return statusCodes;
+ }
+
+ private static bool StatusCodeHasNoResponseBody(HttpStatusCode statusCode)
+ {
+ return statusCode is HttpStatusCode.NoContent or HttpStatusCode.NotModified;
+ }
+
+ private HttpStatusCode[] GetErrorStatusCodesForActionMethod(BuiltinJsonApiActionMethod actionMethod, ResourceType? resourceType)
+ {
+ HttpStatusCode[]? statusCodes = null;
+
+ if (actionMethod is AtomicOperationsActionMethod)
+ {
+ statusCodes =
+ [
+ HttpStatusCode.BadRequest,
+ HttpStatusCode.Forbidden,
+ HttpStatusCode.NotFound,
+ HttpStatusCode.Conflict,
+ HttpStatusCode.UnprocessableEntity
+ ];
+ }
+ else if (actionMethod is JsonApiActionMethod jsonApiActionMethod)
+ {
+ // Condition doesn't apply to atomic operations, because Forbidden is also used when an operation is not accessible.
+ ClientIdGenerationMode clientIdGeneration = resourceType?.ClientIdGeneration ?? _options.ClientIdGeneration;
+
+ statusCodes = jsonApiActionMethod.Endpoint switch
+ {
+ JsonApiEndpoints.GetCollection => [HttpStatusCode.BadRequest],
+ JsonApiEndpoints.GetSingle or JsonApiEndpoints.GetSecondary or JsonApiEndpoints.GetRelationship =>
+ [
+ HttpStatusCode.BadRequest,
+ HttpStatusCode.NotFound
+ ],
+ JsonApiEndpoints.Post when clientIdGeneration == ClientIdGenerationMode.Forbidden =>
+ [
+ HttpStatusCode.BadRequest,
+ HttpStatusCode.Forbidden,
+ HttpStatusCode.NotFound,
+ HttpStatusCode.Conflict,
+ HttpStatusCode.UnprocessableEntity
+ ],
+ JsonApiEndpoints.Post or JsonApiEndpoints.Patch =>
+ [
+ HttpStatusCode.BadRequest,
+ HttpStatusCode.NotFound,
+ HttpStatusCode.Conflict,
+ HttpStatusCode.UnprocessableEntity
+ ],
+ JsonApiEndpoints.Delete => [HttpStatusCode.NotFound],
+ JsonApiEndpoints.PostRelationship or JsonApiEndpoints.PatchRelationship or JsonApiEndpoints.DeleteRelationship =>
+ [
+ HttpStatusCode.BadRequest,
+ HttpStatusCode.NotFound,
+ HttpStatusCode.Conflict,
+ HttpStatusCode.UnprocessableEntity
+ ],
+ _ => null
+ };
+ }
+
+ ConsistencyGuard.ThrowIf(statusCodes == null);
+ return statusCodes;
+ }
+
+ private void SetNonPrimaryResponseMetadata(ActionDescriptor descriptor, BuiltinJsonApiActionMethod actionMethod, ResourceType? resourceType,
+ Dictionary descriptorsByRelationship, RelationshipAttribute relationship, Type? documentType)
+ {
+ ConsistencyGuard.ThrowIf(descriptor.AttributeRouteInfo == null);
+
+ if (!descriptorsByRelationship.TryGetValue(relationship, out ActionDescriptor? relationshipDescriptor))
+ {
+ relationshipDescriptor = Clone(descriptor);
+ RemovePathParameter(relationshipDescriptor.Parameters, "relationshipName");
+ }
+
+ ExpandTemplate(relationshipDescriptor.AttributeRouteInfo!, relationship.PublicName);
+ SetProduces(relationshipDescriptor, documentType);
+ SetProducesResponseTypes(relationshipDescriptor, actionMethod, resourceType, documentType);
+
+ descriptorsByRelationship[relationship] = relationshipDescriptor;
+ }
+
+ [LoggerMessage(Level = LogLevel.Warning, Message = "Hiding unsupported custom JSON:API action method [{HttpMethods}] {ActionMethod} in OpenAPI.")]
+ private partial void LogSuppressedActionMethod(string httpMethods, string? actionMethod);
}
diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/AtomicOperationsActionMethod.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/AtomicOperationsActionMethod.cs
new file mode 100644
index 0000000000..d80a86cd06
--- /dev/null
+++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/AtomicOperationsActionMethod.cs
@@ -0,0 +1,9 @@
+using JsonApiDotNetCore.Controllers;
+
+namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata.ActionMethods;
+
+///
+/// The built-in JSON:API operations action method .
+///
+internal sealed class AtomicOperationsActionMethod(Type controllerType)
+ : BuiltinJsonApiActionMethod(controllerType);
diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/BuiltinJsonApiActionMethod.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/BuiltinJsonApiActionMethod.cs
new file mode 100644
index 0000000000..a6374801b3
--- /dev/null
+++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/BuiltinJsonApiActionMethod.cs
@@ -0,0 +1,18 @@
+using JsonApiDotNetCore.Controllers;
+
+namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata.ActionMethods;
+
+///
+/// A built-in JSON:API action method on .
+///
+internal abstract class BuiltinJsonApiActionMethod : OpenApiActionMethod
+{
+ public Type ControllerType { get; }
+
+ protected BuiltinJsonApiActionMethod(Type controllerType)
+ {
+ ArgumentNullException.ThrowIfNull(controllerType);
+
+ ControllerType = controllerType;
+ }
+}
diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/CustomControllerActionMethod.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/CustomControllerActionMethod.cs
new file mode 100644
index 0000000000..c5b89f27f1
--- /dev/null
+++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/CustomControllerActionMethod.cs
@@ -0,0 +1,13 @@
+namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata.ActionMethods;
+
+///
+/// An action method in a custom controller, unrelated to JSON:API.
+///
+internal sealed class CustomControllerActionMethod : OpenApiActionMethod
+{
+ public static CustomControllerActionMethod Instance { get; } = new();
+
+ private CustomControllerActionMethod()
+ {
+ }
+}
diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/CustomJsonApiActionMethod.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/CustomJsonApiActionMethod.cs
new file mode 100644
index 0000000000..194d623d94
--- /dev/null
+++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/CustomJsonApiActionMethod.cs
@@ -0,0 +1,15 @@
+using JsonApiDotNetCore.Controllers;
+
+namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata.ActionMethods;
+
+///
+/// A custom action method on .
+///
+internal sealed class CustomJsonApiActionMethod : OpenApiActionMethod
+{
+ public static CustomJsonApiActionMethod Instance { get; } = new();
+
+ private CustomJsonApiActionMethod()
+ {
+ }
+}
diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/JsonApiActionMethod.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/JsonApiActionMethod.cs
new file mode 100644
index 0000000000..3cbfeff775
--- /dev/null
+++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/JsonApiActionMethod.cs
@@ -0,0 +1,17 @@
+using JsonApiDotNetCore.Controllers;
+
+namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata.ActionMethods;
+
+///
+/// One of the built-in JSON:API action methods on .
+///
+internal sealed class JsonApiActionMethod(JsonApiEndpoints endpoint, Type controllerType)
+ : BuiltinJsonApiActionMethod(controllerType)
+{
+ public JsonApiEndpoints Endpoint { get; } = endpoint;
+
+ public override string ToString()
+ {
+ return Endpoint.ToString();
+ }
+}
diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/OpenApiActionMethod.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/OpenApiActionMethod.cs
new file mode 100644
index 0000000000..f8519f414a
--- /dev/null
+++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/OpenApiActionMethod.cs
@@ -0,0 +1,58 @@
+using System.Reflection;
+using JsonApiDotNetCore.Controllers;
+using JsonApiDotNetCore.Middleware;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Abstractions;
+using Microsoft.AspNetCore.Mvc.Routing;
+
+namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata.ActionMethods;
+
+internal abstract class OpenApiActionMethod
+{
+ public static OpenApiActionMethod Create(ActionDescriptor descriptor)
+ {
+ ArgumentNullException.ThrowIfNull(descriptor);
+
+ MethodInfo actionMethod = descriptor.GetActionMethod();
+
+ if (IsJsonApiController(actionMethod))
+ {
+ Type? controllerType = actionMethod.ReflectedType;
+ ConsistencyGuard.ThrowIf(controllerType == null);
+
+ if (IsAtomicOperationsController(actionMethod))
+ {
+ var httpPostAttribute = actionMethod.GetCustomAttribute(true);
+
+ if (httpPostAttribute != null)
+ {
+ return new AtomicOperationsActionMethod(controllerType);
+ }
+ }
+ else
+ {
+ IEnumerable httpMethodAttributes = actionMethod.GetCustomAttributes(true);
+ JsonApiEndpoints endpoint = httpMethodAttributes.GetJsonApiEndpoint();
+
+ if (endpoint != JsonApiEndpoints.None)
+ {
+ return new JsonApiActionMethod(endpoint, controllerType);
+ }
+ }
+
+ return CustomJsonApiActionMethod.Instance;
+ }
+
+ return CustomControllerActionMethod.Instance;
+ }
+
+ private static bool IsJsonApiController(MethodInfo controllerAction)
+ {
+ return typeof(CoreJsonApiController).IsAssignableFrom(controllerAction.ReflectedType);
+ }
+
+ private static bool IsAtomicOperationsController(MethodInfo controllerAction)
+ {
+ return typeof(BaseJsonApiOperationsController).IsAssignableFrom(controllerAction.ReflectedType);
+ }
+}
diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/AtomicOperationsRequestMetadata.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/AtomicOperationsRequestMetadata.cs
similarity index 51%
rename from src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/AtomicOperationsRequestMetadata.cs
rename to src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/AtomicOperationsRequestMetadata.cs
index b9b0f44462..9fcfa90989 100644
--- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/AtomicOperationsRequestMetadata.cs
+++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/AtomicOperationsRequestMetadata.cs
@@ -1,9 +1,13 @@
-namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata;
+using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Documents;
+
+namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata.Documents;
internal sealed class AtomicOperationsRequestMetadata : IJsonApiRequestMetadata
{
public static AtomicOperationsRequestMetadata Instance { get; } = new();
+ public Type DocumentType => typeof(OperationsRequestDocument);
+
private AtomicOperationsRequestMetadata()
{
}
diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/AtomicOperationsResponseMetadata.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/AtomicOperationsResponseMetadata.cs
similarity index 51%
rename from src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/AtomicOperationsResponseMetadata.cs
rename to src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/AtomicOperationsResponseMetadata.cs
index 838055c378..f259b76fb4 100644
--- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/AtomicOperationsResponseMetadata.cs
+++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/AtomicOperationsResponseMetadata.cs
@@ -1,9 +1,13 @@
-namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata;
+using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Documents;
+
+namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata.Documents;
internal sealed class AtomicOperationsResponseMetadata : IJsonApiResponseMetadata
{
public static AtomicOperationsResponseMetadata Instance { get; } = new();
+ public Type DocumentType => typeof(OperationsResponseDocument);
+
private AtomicOperationsResponseMetadata()
{
}
diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/EmptyRelationshipResponseMetadata.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/EmptyRelationshipResponseMetadata.cs
new file mode 100644
index 0000000000..3cc784f9c4
--- /dev/null
+++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/EmptyRelationshipResponseMetadata.cs
@@ -0,0 +1,15 @@
+using JsonApiDotNetCore.Resources.Annotations;
+
+namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata.Documents;
+
+internal sealed class EmptyRelationshipResponseMetadata : IJsonApiResponseMetadata
+{
+ public IReadOnlyCollection Relationships { get; }
+
+ public EmptyRelationshipResponseMetadata(IReadOnlyCollection relationships)
+ {
+ ArgumentNullException.ThrowIfNull(relationships);
+
+ Relationships = relationships;
+ }
+}
diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/IJsonApiRequestMetadata.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/IJsonApiRequestMetadata.cs
new file mode 100644
index 0000000000..78206521f0
--- /dev/null
+++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/IJsonApiRequestMetadata.cs
@@ -0,0 +1,3 @@
+namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata.Documents;
+
+internal interface IJsonApiRequestMetadata;
diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/IJsonApiResponseMetadata.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/IJsonApiResponseMetadata.cs
new file mode 100644
index 0000000000..205e8cb4dc
--- /dev/null
+++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/IJsonApiResponseMetadata.cs
@@ -0,0 +1,3 @@
+namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata.Documents;
+
+internal interface IJsonApiResponseMetadata;
diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/JsonApiEndpointMetadata.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/JsonApiEndpointMetadata.cs
new file mode 100644
index 0000000000..4a57c6a686
--- /dev/null
+++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/JsonApiEndpointMetadata.cs
@@ -0,0 +1,7 @@
+namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata.Documents;
+
+internal sealed class JsonApiEndpointMetadata(IJsonApiRequestMetadata? requestMetadata, IJsonApiResponseMetadata? responseMetadata)
+{
+ public IJsonApiRequestMetadata? RequestMetadata { get; } = requestMetadata;
+ public IJsonApiResponseMetadata? ResponseMetadata { get; } = responseMetadata;
+}
diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/NonPrimaryResponseMetadata.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/NonPrimaryResponseMetadata.cs
new file mode 100644
index 0000000000..3a4b7ad432
--- /dev/null
+++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/NonPrimaryResponseMetadata.cs
@@ -0,0 +1,15 @@
+using JsonApiDotNetCore.Resources.Annotations;
+
+namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata.Documents;
+
+internal class NonPrimaryResponseMetadata : IJsonApiResponseMetadata
+{
+ public IReadOnlyDictionary DocumentTypesByRelationship { get; }
+
+ protected NonPrimaryResponseMetadata(IReadOnlyDictionary documentTypesByRelationship)
+ {
+ ArgumentNullException.ThrowIfNull(documentTypesByRelationship);
+
+ DocumentTypesByRelationship = documentTypesByRelationship;
+ }
+}
diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/PrimaryRequestMetadata.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/PrimaryRequestMetadata.cs
similarity index 78%
rename from src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/PrimaryRequestMetadata.cs
rename to src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/PrimaryRequestMetadata.cs
index 7c224417f1..cbcf6ad587 100644
--- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/PrimaryRequestMetadata.cs
+++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/PrimaryRequestMetadata.cs
@@ -1,4 +1,4 @@
-namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata;
+namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata.Documents;
internal sealed class PrimaryRequestMetadata : IJsonApiRequestMetadata
{
diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/PrimaryResponseMetadata.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/PrimaryResponseMetadata.cs
new file mode 100644
index 0000000000..af0761be28
--- /dev/null
+++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/PrimaryResponseMetadata.cs
@@ -0,0 +1,6 @@
+namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata.Documents;
+
+internal sealed class PrimaryResponseMetadata(Type? documentType) : IJsonApiResponseMetadata
+{
+ public Type? DocumentType { get; } = documentType;
+}
diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/RelationshipRequestMetadata.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/RelationshipRequestMetadata.cs
new file mode 100644
index 0000000000..71c82337a8
--- /dev/null
+++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/RelationshipRequestMetadata.cs
@@ -0,0 +1,15 @@
+using JsonApiDotNetCore.Resources.Annotations;
+
+namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata.Documents;
+
+internal sealed class RelationshipRequestMetadata : IJsonApiRequestMetadata
+{
+ public IReadOnlyDictionary DocumentTypesByRelationship { get; }
+
+ public RelationshipRequestMetadata(IReadOnlyDictionary documentTypesByRelationship)
+ {
+ ArgumentNullException.ThrowIfNull(documentTypesByRelationship);
+
+ DocumentTypesByRelationship = documentTypesByRelationship;
+ }
+}
diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/RelationshipResponseMetadata.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/RelationshipResponseMetadata.cs
new file mode 100644
index 0000000000..14d43cd44e
--- /dev/null
+++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/RelationshipResponseMetadata.cs
@@ -0,0 +1,6 @@
+using JsonApiDotNetCore.Resources.Annotations;
+
+namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata.Documents;
+
+internal sealed class RelationshipResponseMetadata(IReadOnlyDictionary documentTypesByRelationship)
+ : NonPrimaryResponseMetadata(documentTypesByRelationship);
diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/SecondaryResponseMetadata.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/SecondaryResponseMetadata.cs
new file mode 100644
index 0000000000..47349ce44e
--- /dev/null
+++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/SecondaryResponseMetadata.cs
@@ -0,0 +1,6 @@
+using JsonApiDotNetCore.Resources.Annotations;
+
+namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata.Documents;
+
+internal sealed class SecondaryResponseMetadata(IReadOnlyDictionary documentTypesByRelationship)
+ : NonPrimaryResponseMetadata(documentTypesByRelationship);
diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/EndpointResolver.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/EndpointResolver.cs
deleted file mode 100644
index e4c074f081..0000000000
--- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/EndpointResolver.cs
+++ /dev/null
@@ -1,38 +0,0 @@
-using System.Reflection;
-using JsonApiDotNetCore.Controllers;
-using JsonApiDotNetCore.Middleware;
-using Microsoft.AspNetCore.Mvc.Routing;
-
-namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata;
-
-internal sealed class EndpointResolver
-{
- public static EndpointResolver Instance { get; } = new();
-
- private EndpointResolver()
- {
- }
-
- public JsonApiEndpoints GetEndpoint(MethodInfo controllerAction)
- {
- ArgumentNullException.ThrowIfNull(controllerAction);
-
- if (!IsJsonApiController(controllerAction))
- {
- return JsonApiEndpoints.None;
- }
-
- IEnumerable httpMethodAttributes = controllerAction.GetCustomAttributes(true);
- return httpMethodAttributes.GetJsonApiEndpoint();
- }
-
- private bool IsJsonApiController(MethodInfo controllerAction)
- {
- return typeof(CoreJsonApiController).IsAssignableFrom(controllerAction.ReflectedType);
- }
-
- public bool IsAtomicOperationsController(MethodInfo controllerAction)
- {
- return typeof(BaseJsonApiOperationsController).IsAssignableFrom(controllerAction.ReflectedType);
- }
-}
diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/IJsonApiEndpointMetadata.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/IJsonApiEndpointMetadata.cs
deleted file mode 100644
index 01a8247ec5..0000000000
--- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/IJsonApiEndpointMetadata.cs
+++ /dev/null
@@ -1,3 +0,0 @@
-namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata;
-
-internal interface IJsonApiEndpointMetadata;
diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/IJsonApiRequestMetadata.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/IJsonApiRequestMetadata.cs
deleted file mode 100644
index 86fbddebb6..0000000000
--- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/IJsonApiRequestMetadata.cs
+++ /dev/null
@@ -1,3 +0,0 @@
-namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata;
-
-internal interface IJsonApiRequestMetadata : IJsonApiEndpointMetadata;
diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/IJsonApiResponseMetadata.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/IJsonApiResponseMetadata.cs
deleted file mode 100644
index 85fb69e856..0000000000
--- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/IJsonApiResponseMetadata.cs
+++ /dev/null
@@ -1,3 +0,0 @@
-namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata;
-
-internal interface IJsonApiResponseMetadata : IJsonApiEndpointMetadata;
diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/JsonApiEndpointMetadataContainer.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/JsonApiEndpointMetadataContainer.cs
deleted file mode 100644
index 60b7182eb6..0000000000
--- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/JsonApiEndpointMetadataContainer.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata;
-
-///
-/// Metadata available at runtime about a JsonApiDotNetCore endpoint.
-///
-internal sealed class JsonApiEndpointMetadataContainer(IJsonApiRequestMetadata? requestMetadata, IJsonApiResponseMetadata? responseMetadata)
-{
- public IJsonApiRequestMetadata? RequestMetadata { get; } = requestMetadata;
- public IJsonApiResponseMetadata? ResponseMetadata { get; } = responseMetadata;
-}
diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/JsonApiEndpointMetadataProvider.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/JsonApiEndpointMetadataProvider.cs
index 6fd6f9e42e..7de84f0345 100644
--- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/JsonApiEndpointMetadataProvider.cs
+++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/JsonApiEndpointMetadataProvider.cs
@@ -1,15 +1,16 @@
-using System.Reflection;
using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCore.Controllers;
using JsonApiDotNetCore.Middleware;
+using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata.ActionMethods;
+using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata.Documents;
using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Documents;
using JsonApiDotNetCore.Resources.Annotations;
+using Microsoft.AspNetCore.Mvc.Abstractions;
namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata;
///
-/// Provides JsonApiDotNetCore related metadata for an ASP.NET controller action that can only be computed from the at
-/// runtime.
+/// Provides JsonApiDotNetCore related metadata for an ASP.NET action method that can only be computed from the at runtime.
///
internal sealed class JsonApiEndpointMetadataProvider
{
@@ -25,28 +26,34 @@ public JsonApiEndpointMetadataProvider(IControllerResourceMapping controllerReso
_nonPrimaryDocumentTypeFactory = nonPrimaryDocumentTypeFactory;
}
- public JsonApiEndpointMetadataContainer Get(MethodInfo controllerAction)
+ public JsonApiEndpointMetadata Get(ActionDescriptor descriptor)
{
- ArgumentNullException.ThrowIfNull(controllerAction);
+ ArgumentNullException.ThrowIfNull(descriptor);
- if (EndpointResolver.Instance.IsAtomicOperationsController(controllerAction))
- {
- return new JsonApiEndpointMetadataContainer(AtomicOperationsRequestMetadata.Instance, AtomicOperationsResponseMetadata.Instance);
- }
-
- JsonApiEndpoints endpoint = EndpointResolver.Instance.GetEndpoint(controllerAction);
+ var actionMethod = OpenApiActionMethod.Create(descriptor);
+ JsonApiEndpointMetadata? metadata = null;
- if (endpoint == JsonApiEndpoints.None)
+ switch (actionMethod)
{
- throw new NotSupportedException($"Unable to provide metadata for non-JSON:API endpoint '{controllerAction.ReflectedType!.FullName}'.");
+ case AtomicOperationsActionMethod:
+ {
+ metadata = new JsonApiEndpointMetadata(AtomicOperationsRequestMetadata.Instance, AtomicOperationsResponseMetadata.Instance);
+ break;
+ }
+ case JsonApiActionMethod jsonApiActionMethod:
+ {
+ ResourceType? primaryResourceType = _controllerResourceMapping.GetResourceTypeForController(jsonApiActionMethod.ControllerType);
+ ConsistencyGuard.ThrowIf(primaryResourceType == null);
+
+ IJsonApiRequestMetadata? requestMetadata = GetRequestMetadata(jsonApiActionMethod.Endpoint, primaryResourceType);
+ IJsonApiResponseMetadata? responseMetadata = GetResponseMetadata(jsonApiActionMethod.Endpoint, primaryResourceType);
+ metadata = new JsonApiEndpointMetadata(requestMetadata, responseMetadata);
+ break;
+ }
}
- ResourceType? primaryResourceType = _controllerResourceMapping.GetResourceTypeForController(controllerAction.ReflectedType);
- ConsistencyGuard.ThrowIf(primaryResourceType == null);
-
- IJsonApiRequestMetadata? requestMetadata = GetRequestMetadata(endpoint, primaryResourceType);
- IJsonApiResponseMetadata? responseMetadata = GetResponseMetadata(endpoint, primaryResourceType);
- return new JsonApiEndpointMetadataContainer(requestMetadata, responseMetadata);
+ ConsistencyGuard.ThrowIf(metadata == null);
+ return metadata;
}
private IJsonApiRequestMetadata? GetRequestMetadata(JsonApiEndpoints endpoint, ResourceType primaryResourceType)
@@ -75,14 +82,14 @@ private static PrimaryRequestMetadata GetPatchResourceRequestMetadata(Type resou
return new PrimaryRequestMetadata(documentType);
}
- private RelationshipRequestMetadata GetRelationshipRequestMetadata(IEnumerable relationships, bool ignoreHasOneRelationships)
+ private RelationshipRequestMetadata GetRelationshipRequestMetadata(IReadOnlyCollection relationships, bool ignoreHasOneRelationships)
{
IEnumerable relationshipsOfEndpoint = ignoreHasOneRelationships ? relationships.OfType() : relationships;
- IDictionary requestDocumentTypesByRelationshipName = relationshipsOfEndpoint.ToDictionary(relationship => relationship.PublicName,
+ Dictionary documentTypesByRelationship = relationshipsOfEndpoint.ToDictionary(relationship => relationship,
_nonPrimaryDocumentTypeFactory.GetForRelationshipRequest);
- return new RelationshipRequestMetadata(requestDocumentTypesByRelationshipName);
+ return new RelationshipRequestMetadata(documentTypesByRelationship.AsReadOnly());
}
private IJsonApiResponseMetadata? GetResponseMetadata(JsonApiEndpoints endpoint, ResourceType primaryResourceType)
@@ -91,12 +98,20 @@ private RelationshipRequestMetadata GetRelationshipRequestMetadata(IEnumerable GetPrimaryResponseMetadata(
primaryResourceType.ClrType, endpoint == JsonApiEndpoints.GetCollection),
+ JsonApiEndpoints.Delete => GetEmptyPrimaryResponseMetadata(),
JsonApiEndpoints.GetSecondary => GetSecondaryResponseMetadata(primaryResourceType.Relationships),
JsonApiEndpoints.GetRelationship => GetRelationshipResponseMetadata(primaryResourceType.Relationships),
+ JsonApiEndpoints.PostRelationship or JsonApiEndpoints.PatchRelationship or JsonApiEndpoints.DeleteRelationship =>
+ GetEmptyRelationshipResponseMetadata(primaryResourceType.Relationships, endpoint != JsonApiEndpoints.PatchRelationship),
_ => null
};
}
+ private static PrimaryResponseMetadata GetEmptyPrimaryResponseMetadata()
+ {
+ return new PrimaryResponseMetadata(null);
+ }
+
private static PrimaryResponseMetadata GetPrimaryResponseMetadata(Type resourceClrType, bool endpointReturnsCollection)
{
Type documentOpenType = endpointReturnsCollection ? typeof(CollectionResponseDocument<>) : typeof(PrimaryResponseDocument<>);
@@ -107,17 +122,26 @@ private static PrimaryResponseMetadata GetPrimaryResponseMetadata(Type resourceC
private SecondaryResponseMetadata GetSecondaryResponseMetadata(IEnumerable relationships)
{
- IDictionary responseDocumentTypesByRelationshipName = relationships.ToDictionary(relationship => relationship.PublicName,
+ Dictionary documentTypesByRelationship = relationships.ToDictionary(relationship => relationship,
_nonPrimaryDocumentTypeFactory.GetForSecondaryResponse);
- return new SecondaryResponseMetadata(responseDocumentTypesByRelationshipName);
+ return new SecondaryResponseMetadata(documentTypesByRelationship.AsReadOnly());
}
- private RelationshipResponseMetadata GetRelationshipResponseMetadata(IEnumerable relationships)
+ private RelationshipResponseMetadata GetRelationshipResponseMetadata(IReadOnlyCollection relationships)
{
- IDictionary responseDocumentTypesByRelationshipName = relationships.ToDictionary(relationship => relationship.PublicName,
+ Dictionary documentTypesByRelationship = relationships.ToDictionary(relationship => relationship,
_nonPrimaryDocumentTypeFactory.GetForRelationshipResponse);
- return new RelationshipResponseMetadata(responseDocumentTypesByRelationshipName);
+ return new RelationshipResponseMetadata(documentTypesByRelationship.AsReadOnly());
+ }
+
+ private static EmptyRelationshipResponseMetadata GetEmptyRelationshipResponseMetadata(IReadOnlyCollection relationships,
+ bool ignoreHasOneRelationships)
+ {
+ IReadOnlyCollection