diff --git a/Build.ps1 b/Build.ps1 index e3c05d0f89..0b5b8d190d 100644 --- a/Build.ps1 +++ b/Build.ps1 @@ -86,10 +86,12 @@ function CreateNuGetPackage { if ([string]::IsNullOrWhitespace($versionSuffix)) { dotnet pack .\src\JsonApiDotNetCore -c Release -o .\artifacts dotnet pack .\src\JsonApiDotNetCore.OpenApi -c Release -o .\artifacts + dotnet pack .\src\JsonApiDotNetCore.OpenApi.Client -c Release -o .\artifacts } else { dotnet pack .\src\JsonApiDotNetCore -c Release -o .\artifacts --version-suffix=$versionSuffix dotnet pack .\src\JsonApiDotNetCore.OpenApi -c Release -o .\artifacts --version-suffix=$versionSuffix + dotnet pack .\src\JsonApiDotNetCore.OpenApi.Client -c Release -o .\artifacts --version-suffix=$versionSuffix } CheckLastExitCode diff --git a/JsonApiDotNetCore.sln b/JsonApiDotNetCore.sln index fcb0603b0e..311a1a586c 100644 --- a/JsonApiDotNetCore.sln +++ b/JsonApiDotNetCore.sln @@ -48,6 +48,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JsonApiDotNetCore.OpenApi", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenApiTests", "test\OpenApiTests\OpenApiTests.csproj", "{B693DE14-BB28-496F-AB39-B4E674ABCA80}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JsonApiDotNetCore.OpenApi.Client", "src\JsonApiDotNetCore.OpenApi.Client\JsonApiDotNetCore.OpenApi.Client.csproj", "{5ADAA902-5A75-4ECB-B4B4-03291D63CE9C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JsonApiDotNetCoreExampleClient", "src\Examples\JsonApiDotNetCoreExampleClient\JsonApiDotNetCoreExampleClient.csproj", "{7FC5DFA3-6F66-4FD8-820D-81E93856F252}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenApiClientTests", "test\OpenApiClientTests\OpenApiClientTests.csproj", "{77F98215-3085-422E-B99D-4C404C2114CF}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -238,6 +244,42 @@ Global {B693DE14-BB28-496F-AB39-B4E674ABCA80}.Release|x64.Build.0 = Release|Any CPU {B693DE14-BB28-496F-AB39-B4E674ABCA80}.Release|x86.ActiveCfg = Release|Any CPU {B693DE14-BB28-496F-AB39-B4E674ABCA80}.Release|x86.Build.0 = Release|Any CPU + {5ADAA902-5A75-4ECB-B4B4-03291D63CE9C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5ADAA902-5A75-4ECB-B4B4-03291D63CE9C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5ADAA902-5A75-4ECB-B4B4-03291D63CE9C}.Debug|x64.ActiveCfg = Debug|Any CPU + {5ADAA902-5A75-4ECB-B4B4-03291D63CE9C}.Debug|x64.Build.0 = Debug|Any CPU + {5ADAA902-5A75-4ECB-B4B4-03291D63CE9C}.Debug|x86.ActiveCfg = Debug|Any CPU + {5ADAA902-5A75-4ECB-B4B4-03291D63CE9C}.Debug|x86.Build.0 = Debug|Any CPU + {5ADAA902-5A75-4ECB-B4B4-03291D63CE9C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5ADAA902-5A75-4ECB-B4B4-03291D63CE9C}.Release|Any CPU.Build.0 = Release|Any CPU + {5ADAA902-5A75-4ECB-B4B4-03291D63CE9C}.Release|x64.ActiveCfg = Release|Any CPU + {5ADAA902-5A75-4ECB-B4B4-03291D63CE9C}.Release|x64.Build.0 = Release|Any CPU + {5ADAA902-5A75-4ECB-B4B4-03291D63CE9C}.Release|x86.ActiveCfg = Release|Any CPU + {5ADAA902-5A75-4ECB-B4B4-03291D63CE9C}.Release|x86.Build.0 = Release|Any CPU + {7FC5DFA3-6F66-4FD8-820D-81E93856F252}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7FC5DFA3-6F66-4FD8-820D-81E93856F252}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7FC5DFA3-6F66-4FD8-820D-81E93856F252}.Debug|x64.ActiveCfg = Debug|Any CPU + {7FC5DFA3-6F66-4FD8-820D-81E93856F252}.Debug|x64.Build.0 = Debug|Any CPU + {7FC5DFA3-6F66-4FD8-820D-81E93856F252}.Debug|x86.ActiveCfg = Debug|Any CPU + {7FC5DFA3-6F66-4FD8-820D-81E93856F252}.Debug|x86.Build.0 = Debug|Any CPU + {7FC5DFA3-6F66-4FD8-820D-81E93856F252}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7FC5DFA3-6F66-4FD8-820D-81E93856F252}.Release|Any CPU.Build.0 = Release|Any CPU + {7FC5DFA3-6F66-4FD8-820D-81E93856F252}.Release|x64.ActiveCfg = Release|Any CPU + {7FC5DFA3-6F66-4FD8-820D-81E93856F252}.Release|x64.Build.0 = Release|Any CPU + {7FC5DFA3-6F66-4FD8-820D-81E93856F252}.Release|x86.ActiveCfg = Release|Any CPU + {7FC5DFA3-6F66-4FD8-820D-81E93856F252}.Release|x86.Build.0 = Release|Any CPU + {77F98215-3085-422E-B99D-4C404C2114CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {77F98215-3085-422E-B99D-4C404C2114CF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {77F98215-3085-422E-B99D-4C404C2114CF}.Debug|x64.ActiveCfg = Debug|Any CPU + {77F98215-3085-422E-B99D-4C404C2114CF}.Debug|x64.Build.0 = Debug|Any CPU + {77F98215-3085-422E-B99D-4C404C2114CF}.Debug|x86.ActiveCfg = Debug|Any CPU + {77F98215-3085-422E-B99D-4C404C2114CF}.Debug|x86.Build.0 = Debug|Any CPU + {77F98215-3085-422E-B99D-4C404C2114CF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {77F98215-3085-422E-B99D-4C404C2114CF}.Release|Any CPU.Build.0 = Release|Any CPU + {77F98215-3085-422E-B99D-4C404C2114CF}.Release|x64.ActiveCfg = Release|Any CPU + {77F98215-3085-422E-B99D-4C404C2114CF}.Release|x64.Build.0 = Release|Any CPU + {77F98215-3085-422E-B99D-4C404C2114CF}.Release|x86.ActiveCfg = Release|Any CPU + {77F98215-3085-422E-B99D-4C404C2114CF}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -258,6 +300,9 @@ Global {210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} {71287D6F-6C3B-44B4-9FCA-E78FE3F02289} = {7A2B7ADD-ECB5-4D00-AA6A-D45BD11C97CF} {B693DE14-BB28-496F-AB39-B4E674ABCA80} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} + {5ADAA902-5A75-4ECB-B4B4-03291D63CE9C} = {7A2B7ADD-ECB5-4D00-AA6A-D45BD11C97CF} + {7FC5DFA3-6F66-4FD8-820D-81E93856F252} = {026FBC6C-AF76-4568-9B87-EC73457899FD} + {77F98215-3085-422E-B99D-4C404C2114CF} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {A2421882-8F0A-4905-928F-B550B192F9A4} diff --git a/docs/usage/openapi-client.md b/docs/usage/openapi-client.md new file mode 100644 index 0000000000..7250bb55ce --- /dev/null +++ b/docs/usage/openapi-client.md @@ -0,0 +1,139 @@ +# OpenAPI Client + +You can generate a JSON:API client in various programming languages from the [OpenAPI specification](https://swagger.io/specification/) file that JsonApiDotNetCore APIs provide. + +For C# .NET clients generated using [NSwag](https://github.com/RicoSuter/NSwag), we provide an additional package that introduces support for partial PATCH/POST requests. The issue here is that a property on a generated C# class being `null` could mean "set the value to `null` in the request" or "this is `null` because I never touched it". + +## Getting started + +### Visual Studio + +The easiest way to get started is by using the built-in capabilities of Visual Studio. The next steps describe how to generate a JSON:API client library and use our package. + +1. In **Solution Explorer**, right-click your client project, select **Add** > **Service Reference** and choose **OpenAPI**. + +2. On the next page, specify the OpenAPI URL to your JSON:API server, for example: `http://localhost:14140/swagger/v1/swagger.json`. + Optionally provide a class name and namespace and click **Finish**. + Visual Studio now downloads your swagger.json and updates your project file. This results in a pre-build step that generates the client code. + + Tip: To later re-download swagger.json and regenerate the client code, right-click **Dependencies** > **Manage Connected Services** and click the **Refresh** icon. +3. Although not strictly required, we recommend to run package update now, which fixes some issues and removes the `Stream` parameter from generated calls. + +4. Add some demo code that calls one of your JSON:API endpoints. For example: + + ```c# + using var httpClient = new HttpClient(); + var apiClient = new ExampleApiClient("http://localhost:14140", httpClient); + + PersonCollectionResponseDocument getResponse = + await apiClient.GetPersonCollectionAsync(); + + foreach (PersonDataInResponse person in getResponse.Data) + { + Console.WriteLine($"Found user {person.Id} named " + + $"'{person.Attributes.FirstName} {person.Attributes.LastName}'."); + } + ``` + +5. Add our client package to your project: + + ``` + dotnet add package JsonApiDotNetCore.OpenApi.Client + ``` + +6. Add the following glue code to connect our package with your generated code. The code below assumes you specified `ExampleApiClient` as class name in step 2. + + ```c# + using JsonApiDotNetCore.OpenApi.Client; + using Newtonsoft.Json; + + partial class ExampleApiClient : JsonApiClient + { + partial void UpdateJsonSerializerSettings(JsonSerializerSettings settings) + { + SetSerializerSettingsForJsonApi(settings); + } + } + ``` + +7. Extend your demo code to send a partial PATCH request with the help of our package: + + ```c# + var patchRequest = new PersonPatchRequestDocument + { + Data = new PersonDataInPatchRequest + { + Id = "1", + Attributes = new PersonAttributesInPatchRequest + { + FirstName = "Jack" + } + } + }; + + // This line results in sending "lastName: null" instead of omitting it. + using (apiClient.RegisterAttributesForRequestDocument(patchRequest, person => person.LastName)) + { + PersonPrimaryResponseDocument patchResponse = + await apiClient.PatchPersonAsync("1", patchRequest); + + // The sent request looks like this: + // { + // "data": { + // "type": "people", + // "id": "1", + // "attributes": { + // "firstName": "Jack", + // "lastName": null + // } + // } + // } + } + ``` + +### Other IDEs + +When using the command-line, you can try the [Microsoft.dotnet-openapi Global Tool](https://docs.microsoft.com/en-us/aspnet/core/web-api/microsoft.dotnet-openapi?view=aspnetcore-5.0). + +Alternatively, the next section shows what to add to your client project file directly: + +```xml + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + http://localhost:14140/swagger/v1/swagger.json + + +``` + +From here, continue from step 3 in the list of steps for Visual Studio. + +## Configuration + +### NSwag + +The `OpenApiReference` element in the project file accepts an `Options` element to pass additional settings to the client generator, +which are listed [here](https://github.com/RicoSuter/NSwag/blob/master/src/NSwag.Commands/Commands/CodeGeneration/OpenApiToCSharpClientCommand.cs). + +For example, the next section puts the generated code in a namespace, removes the `baseUrl` parameter and generates an interface (which is handy for dependency injection): + +```xml + + ExampleProject.GeneratedCode + SalesApiClient + NSwagCSharp + /UseBaseUrl:false /GenerateClientInterfaces:true + +``` diff --git a/docs/usage/openapi.md b/docs/usage/openapi.md index 79156c5de7..07e731dce7 100644 --- a/docs/usage/openapi.md +++ b/docs/usage/openapi.md @@ -1,64 +1,49 @@ # OpenAPI -You can describe your API with an OpenAPI specification using the [Swashbuckle](https://github.com/domaindrivendev/Swashbuckle.AspNetCore) integration for JsonApiDotNetCore. +JsonApiDotNetCore provides an extension package that enables you to produce an [OpenAPI specification](https://swagger.io/specification/) for your JSON:API endpoints. This can be used to generate a [documentation website](https://swagger.io/tools/swagger-ui/) or to generate [client libraries](https://openapi-generator.tech/docs/generators/) in various languages. The package provides an integration with [Swashbuckle](https://github.com/domaindrivendev/Swashbuckle.AspNetCore). -## Installation -Install the `JsonApiDotNetCore.OpenApi` NuGet package. +## Getting started -### CLI +1. Install the `JsonApiDotNetCore.OpenApi` NuGet package: -``` -dotnet add package JsonApiDotNetCore.OpenApi -``` - -### Visual Studio - -```powershell -Install-Package JsonApiDotNetCore.OpenApi -``` - -### *.csproj - -```xml - - - - -``` - -## Usage + ``` + dotnet add package JsonApiDotNetCore.OpenApi + ``` -Add the integration in your `Startup` class. +2. Add the integration in your `Startup` class. -```c# -public class Startup -{ - public void ConfigureServices(IServiceCollection services) + ```c# + public class Startup { - IMvcCoreBuilder mvcBuilder = services.AddMvcCore(); - services.AddJsonApi(mvcBuilder: mvcBuilder); + public void ConfigureServices(IServiceCollection services) + { + IMvcCoreBuilder mvcBuilder = services.AddMvcCore(); - // Adds the Swashbuckle integration. - services.AddOpenApi(mvcBuilder); - } + services.AddJsonApi(mvcBuilder: mvcBuilder); - public void Configure(IApplicationBuilder app, IWebHostEnvironment env) - { - app.UseRouting(); - app.UseJsonApi(); + // Adds the Swashbuckle integration. + services.AddOpenApi(mvcBuilder); + } + + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + app.UseRouting(); + app.UseJsonApi(); - // Adds the Swashbuckle middleware. - app.UseSwagger(); + // Adds the Swashbuckle middleware. + app.UseSwagger(); - app.UseEndpoints(endpoints => endpoints.MapControllers()); + app.UseEndpoints(endpoints => endpoints.MapControllers()); + } } -} -``` + ``` By default, the OpenAPI specification will be available at `http://localhost:/swagger/v1/swagger.json`. -Swashbuckle also ships with [SwaggerUI](https://swagger.io/tools/swagger-ui/), tooling for a generated documentation page. This can be enabled by installing the `Swashbuckle.AspNetCore.SwaggerUI` NuGet package and adding the following to your `Startup` class. +## Documentation + +Swashbuckle also ships with [SwaggerUI](https://swagger.io/tools/swagger-ui/), tooling for a generated documentation page. This can be enabled by installing the `Swashbuckle.AspNetCore.SwaggerUI` NuGet package and adding the following to your `Startup` class: ```c# // Startup.cs diff --git a/docs/usage/toc.md b/docs/usage/toc.md index 76ea777aac..10fee6bc72 100644 --- a/docs/usage/toc.md +++ b/docs/usage/toc.md @@ -21,7 +21,9 @@ # [Errors](errors.md) # [Metadata](meta.md) # [Caching](caching.md) + # [OpenAPI](openapi.md) +## [Client](openapi-client.md) # Extensibility ## [Layer Overview](extensibility/layer-overview.md) diff --git a/src/Examples/JsonApiDotNetCoreExampleClient/ExampleApiClient.cs b/src/Examples/JsonApiDotNetCoreExampleClient/ExampleApiClient.cs new file mode 100644 index 0000000000..52878e55e3 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExampleClient/ExampleApiClient.cs @@ -0,0 +1,15 @@ +using JsonApiDotNetCore.OpenApi.Client; +using Newtonsoft.Json; + +namespace JsonApiDotNetCoreExampleClient +{ + public partial class ExampleApiClient : JsonApiClient + { + partial void UpdateJsonSerializerSettings(JsonSerializerSettings settings) + { + SetSerializerSettingsForJsonApi(settings); + + settings.Formatting = Formatting.Indented; + } + } +} diff --git a/src/Examples/JsonApiDotNetCoreExampleClient/JsonApiDotNetCoreExampleClient.csproj b/src/Examples/JsonApiDotNetCoreExampleClient/JsonApiDotNetCoreExampleClient.csproj new file mode 100644 index 0000000000..b736fd0859 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExampleClient/JsonApiDotNetCoreExampleClient.csproj @@ -0,0 +1,28 @@ + + + Exe + $(NetCoreAppVersion) + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + http://localhost:14140/swagger/v1/swagger.json + + + diff --git a/src/Examples/JsonApiDotNetCoreExampleClient/OpenAPIs/swagger.json b/src/Examples/JsonApiDotNetCoreExampleClient/OpenAPIs/swagger.json new file mode 100644 index 0000000000..9a72c99145 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExampleClient/OpenAPIs/swagger.json @@ -0,0 +1,2653 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "JsonApiDotNetCoreExample", + "version": "1.0" + }, + "paths": { + "/api/v1/people": { + "get": { + "tags": [ + "people" + ], + "operationId": "get person Collection", + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/person-collection-response-document" + } + } + } + } + } + }, + "head": { + "tags": [ + "people" + ], + "operationId": "head person Collection", + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/person-collection-response-document" + } + } + } + } + } + }, + "post": { + "tags": [ + "people" + ], + "operationId": "post person", + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/person-post-request-document" + } + } + } + }, + "responses": { + "201": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/person-primary-response-document" + } + } + } + }, + "204": { + "description": "Success" + } + } + } + }, + "/api/v1/people/{id}": { + "get": { + "tags": [ + "people" + ], + "operationId": "get person", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/person-primary-response-document" + } + } + } + } + } + }, + "head": { + "tags": [ + "people" + ], + "operationId": "head person", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/person-primary-response-document" + } + } + } + } + } + }, + "patch": { + "tags": [ + "people" + ], + "operationId": "patch person", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/person-patch-request-document" + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/person-primary-response-document" + } + } + } + }, + "204": { + "description": "Success" + } + } + }, + "delete": { + "tags": [ + "people" + ], + "operationId": "delete person", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "204": { + "description": "Success" + } + } + } + }, + "/api/v1/people/{id}/assignedTodoItems": { + "get": { + "tags": [ + "people" + ], + "operationId": "get person assignedTodoItems", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/todoItem-collection-response-document" + } + } + } + } + } + }, + "head": { + "tags": [ + "people" + ], + "operationId": "head person assignedTodoItems", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/todoItem-collection-response-document" + } + } + } + } + } + } + }, + "/api/v1/people/{id}/relationships/assignedTodoItems": { + "get": { + "tags": [ + "people" + ], + "operationId": "get person assignedTodoItems Relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/todoItem-identifier-collection-response-document" + } + } + } + } + } + }, + "head": { + "tags": [ + "people" + ], + "operationId": "head person assignedTodoItems Relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/todoItem-identifier-collection-response-document" + } + } + } + } + } + }, + "post": { + "tags": [ + "people" + ], + "operationId": "post person assignedTodoItems Relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/to-many-todoItem-request-data" + } + } + } + }, + "responses": { + "204": { + "description": "Success" + } + } + }, + "patch": { + "tags": [ + "people" + ], + "operationId": "patch person assignedTodoItems Relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/to-many-todoItem-request-data" + } + } + } + }, + "responses": { + "204": { + "description": "Success" + } + } + }, + "delete": { + "tags": [ + "people" + ], + "operationId": "delete person assignedTodoItems Relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/to-many-todoItem-request-data" + } + } + } + }, + "responses": { + "204": { + "description": "Success" + } + } + } + }, + "/api/v1/tags": { + "get": { + "tags": [ + "tags" + ], + "operationId": "get tag Collection", + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/tag-collection-response-document" + } + } + } + } + } + }, + "head": { + "tags": [ + "tags" + ], + "operationId": "head tag Collection", + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/tag-collection-response-document" + } + } + } + } + } + }, + "post": { + "tags": [ + "tags" + ], + "operationId": "post tag", + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/tag-post-request-document" + } + } + } + }, + "responses": { + "201": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/tag-primary-response-document" + } + } + } + }, + "204": { + "description": "Success" + } + } + } + }, + "/api/v1/tags/{id}": { + "get": { + "tags": [ + "tags" + ], + "operationId": "get tag", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/tag-primary-response-document" + } + } + } + } + } + }, + "head": { + "tags": [ + "tags" + ], + "operationId": "head tag", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/tag-primary-response-document" + } + } + } + } + } + }, + "patch": { + "tags": [ + "tags" + ], + "operationId": "patch tag", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/tag-patch-request-document" + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/tag-primary-response-document" + } + } + } + }, + "204": { + "description": "Success" + } + } + }, + "delete": { + "tags": [ + "tags" + ], + "operationId": "delete tag", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "204": { + "description": "Success" + } + } + } + }, + "/api/v1/tags/{id}/todoItems": { + "get": { + "tags": [ + "tags" + ], + "operationId": "get tag todoItems", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/todoItem-collection-response-document" + } + } + } + } + } + }, + "head": { + "tags": [ + "tags" + ], + "operationId": "head tag todoItems", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/todoItem-collection-response-document" + } + } + } + } + } + } + }, + "/api/v1/tags/{id}/relationships/todoItems": { + "get": { + "tags": [ + "tags" + ], + "operationId": "get tag todoItems Relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/todoItem-identifier-collection-response-document" + } + } + } + } + } + }, + "head": { + "tags": [ + "tags" + ], + "operationId": "head tag todoItems Relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/todoItem-identifier-collection-response-document" + } + } + } + } + } + }, + "post": { + "tags": [ + "tags" + ], + "operationId": "post tag todoItems Relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/to-many-todoItem-request-data" + } + } + } + }, + "responses": { + "204": { + "description": "Success" + } + } + }, + "patch": { + "tags": [ + "tags" + ], + "operationId": "patch tag todoItems Relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/to-many-todoItem-request-data" + } + } + } + }, + "responses": { + "204": { + "description": "Success" + } + } + }, + "delete": { + "tags": [ + "tags" + ], + "operationId": "delete tag todoItems Relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/to-many-todoItem-request-data" + } + } + } + }, + "responses": { + "204": { + "description": "Success" + } + } + } + }, + "/api/v1/todoItems": { + "get": { + "tags": [ + "todoItems" + ], + "operationId": "get todoItem Collection", + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/todoItem-collection-response-document" + } + } + } + } + } + }, + "head": { + "tags": [ + "todoItems" + ], + "operationId": "head todoItem Collection", + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/todoItem-collection-response-document" + } + } + } + } + } + }, + "post": { + "tags": [ + "todoItems" + ], + "operationId": "post todoItem", + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/todoItem-post-request-document" + } + } + } + }, + "responses": { + "201": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/todoItem-primary-response-document" + } + } + } + }, + "204": { + "description": "Success" + } + } + } + }, + "/api/v1/todoItems/{id}": { + "get": { + "tags": [ + "todoItems" + ], + "operationId": "get todoItem", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/todoItem-primary-response-document" + } + } + } + } + } + }, + "head": { + "tags": [ + "todoItems" + ], + "operationId": "head todoItem", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/todoItem-primary-response-document" + } + } + } + } + } + }, + "patch": { + "tags": [ + "todoItems" + ], + "operationId": "patch todoItem", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/todoItem-patch-request-document" + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/todoItem-primary-response-document" + } + } + } + }, + "204": { + "description": "Success" + } + } + }, + "delete": { + "tags": [ + "todoItems" + ], + "operationId": "delete todoItem", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "204": { + "description": "Success" + } + } + } + }, + "/api/v1/todoItems/{id}/assignee": { + "get": { + "tags": [ + "todoItems" + ], + "operationId": "get todoItem assignee", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/person-secondary-response-document" + } + } + } + } + } + }, + "head": { + "tags": [ + "todoItems" + ], + "operationId": "head todoItem assignee", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/person-secondary-response-document" + } + } + } + } + } + } + }, + "/api/v1/todoItems/{id}/relationships/assignee": { + "get": { + "tags": [ + "todoItems" + ], + "operationId": "get todoItem assignee Relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/person-identifier-response-document" + } + } + } + } + } + }, + "head": { + "tags": [ + "todoItems" + ], + "operationId": "head todoItem assignee Relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/person-identifier-response-document" + } + } + } + } + } + }, + "patch": { + "tags": [ + "todoItems" + ], + "operationId": "patch todoItem assignee Relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/to-one-person-request-data" + } + } + } + }, + "responses": { + "204": { + "description": "Success" + } + } + } + }, + "/api/v1/todoItems/{id}/owner": { + "get": { + "tags": [ + "todoItems" + ], + "operationId": "get todoItem owner", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/person-secondary-response-document" + } + } + } + } + } + }, + "head": { + "tags": [ + "todoItems" + ], + "operationId": "head todoItem owner", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/person-secondary-response-document" + } + } + } + } + } + } + }, + "/api/v1/todoItems/{id}/relationships/owner": { + "get": { + "tags": [ + "todoItems" + ], + "operationId": "get todoItem owner Relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/person-identifier-response-document" + } + } + } + } + } + }, + "head": { + "tags": [ + "todoItems" + ], + "operationId": "head todoItem owner Relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/person-identifier-response-document" + } + } + } + } + } + }, + "patch": { + "tags": [ + "todoItems" + ], + "operationId": "patch todoItem owner Relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/to-one-person-request-data" + } + } + } + }, + "responses": { + "204": { + "description": "Success" + } + } + } + }, + "/api/v1/todoItems/{id}/tags": { + "get": { + "tags": [ + "todoItems" + ], + "operationId": "get todoItem tags", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/tag-collection-response-document" + } + } + } + } + } + }, + "head": { + "tags": [ + "todoItems" + ], + "operationId": "head todoItem tags", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/tag-collection-response-document" + } + } + } + } + } + } + }, + "/api/v1/todoItems/{id}/relationships/tags": { + "get": { + "tags": [ + "todoItems" + ], + "operationId": "get todoItem tags Relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/tag-identifier-collection-response-document" + } + } + } + } + } + }, + "head": { + "tags": [ + "todoItems" + ], + "operationId": "head todoItem tags Relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/tag-identifier-collection-response-document" + } + } + } + } + } + }, + "post": { + "tags": [ + "todoItems" + ], + "operationId": "post todoItem tags Relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/to-many-tag-request-data" + } + } + } + }, + "responses": { + "204": { + "description": "Success" + } + } + }, + "patch": { + "tags": [ + "todoItems" + ], + "operationId": "patch todoItem tags Relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/to-many-tag-request-data" + } + } + } + }, + "responses": { + "204": { + "description": "Success" + } + } + }, + "delete": { + "tags": [ + "todoItems" + ], + "operationId": "delete todoItem tags Relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/to-many-tag-request-data" + } + } + } + }, + "responses": { + "204": { + "description": "Success" + } + } + } + } + }, + "components": { + "schemas": { + "jsonapiObject": { + "type": "object", + "properties": { + "version": { + "type": "string" + }, + "ext": { + "type": "array", + "items": { + "type": "string" + } + }, + "profile": { + "type": "array", + "items": { + "type": "string" + } + }, + "meta": { + "type": "object", + "additionalProperties": {} + } + }, + "additionalProperties": false + }, + "linksInRelationshipObject": { + "required": [ + "related", + "self" + ], + "type": "object", + "properties": { + "self": { + "type": "string" + }, + "related": { + "type": "string" + } + }, + "additionalProperties": false + }, + "linksInResourceCollectionDocument": { + "required": [ + "first", + "self" + ], + "type": "object", + "properties": { + "self": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "first": { + "type": "string" + }, + "last": { + "type": "string" + }, + "prev": { + "type": "string" + }, + "next": { + "type": "string" + } + }, + "additionalProperties": false + }, + "linksInResourceDocument": { + "required": [ + "self" + ], + "type": "object", + "properties": { + "self": { + "type": "string" + }, + "describedby": { + "type": "string" + } + }, + "additionalProperties": false + }, + "linksInResourceIdentifierCollectionDocument": { + "required": [ + "first", + "related", + "self" + ], + "type": "object", + "properties": { + "self": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "related": { + "type": "string" + }, + "first": { + "type": "string" + }, + "last": { + "type": "string" + }, + "prev": { + "type": "string" + }, + "next": { + "type": "string" + } + }, + "additionalProperties": false + }, + "linksInResourceIdentifierDocument": { + "required": [ + "related", + "self" + ], + "type": "object", + "properties": { + "self": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "related": { + "type": "string" + } + }, + "additionalProperties": false + }, + "linksInResourceObject": { + "required": [ + "self" + ], + "type": "object", + "properties": { + "self": { + "type": "string" + } + }, + "additionalProperties": false + }, + "null-value": { + "not": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "object" + }, + { + "type": "array" + } + ], + "items": {} + }, + "nullable": true + }, + "people-resource-type": { + "enum": [ + "people" + ], + "type": "string" + }, + "person-attributes-in-patch-request": { + "type": "object", + "properties": { + "firstName": { + "type": "string", + "nullable": true + }, + "lastName": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "person-attributes-in-post-request": { + "type": "object", + "properties": { + "firstName": { + "type": "string", + "nullable": true + }, + "lastName": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "person-attributes-in-response": { + "type": "object", + "properties": { + "firstName": { + "type": "string", + "nullable": true + }, + "lastName": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "person-collection-response-document": { + "required": [ + "data", + "links" + ], + "type": "object", + "properties": { + "meta": { + "type": "object", + "additionalProperties": {} + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapiObject" + }, + "links": { + "$ref": "#/components/schemas/linksInResourceCollectionDocument" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/person-data-in-response" + } + } + }, + "additionalProperties": false + }, + "person-data-in-patch-request": { + "required": [ + "id", + "type" + ], + "type": "object", + "properties": { + "type": { + "$ref": "#/components/schemas/people-resource-type" + }, + "id": { + "type": "string" + }, + "attributes": { + "$ref": "#/components/schemas/person-attributes-in-patch-request" + }, + "relationships": { + "$ref": "#/components/schemas/person-relationships-in-patch-request" + } + }, + "additionalProperties": false + }, + "person-data-in-post-request": { + "required": [ + "type" + ], + "type": "object", + "properties": { + "type": { + "$ref": "#/components/schemas/people-resource-type" + }, + "attributes": { + "$ref": "#/components/schemas/person-attributes-in-post-request" + }, + "relationships": { + "$ref": "#/components/schemas/person-relationships-in-post-request" + } + }, + "additionalProperties": false + }, + "person-data-in-response": { + "required": [ + "id", + "links", + "type" + ], + "type": "object", + "properties": { + "type": { + "$ref": "#/components/schemas/people-resource-type" + }, + "id": { + "type": "string" + }, + "attributes": { + "$ref": "#/components/schemas/person-attributes-in-response" + }, + "relationships": { + "$ref": "#/components/schemas/person-relationships-in-response" + }, + "links": { + "$ref": "#/components/schemas/linksInResourceObject" + }, + "meta": { + "type": "object", + "additionalProperties": {} + } + }, + "additionalProperties": false + }, + "person-identifier": { + "required": [ + "id", + "type" + ], + "type": "object", + "properties": { + "type": { + "$ref": "#/components/schemas/people-resource-type" + }, + "id": { + "type": "string" + } + }, + "additionalProperties": false + }, + "person-identifier-response-document": { + "required": [ + "data", + "links" + ], + "type": "object", + "properties": { + "meta": { + "type": "object", + "additionalProperties": {} + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapiObject" + }, + "links": { + "$ref": "#/components/schemas/linksInResourceIdentifierDocument" + }, + "data": { + "oneOf": [ + { + "$ref": "#/components/schemas/person-identifier" + }, + { + "$ref": "#/components/schemas/null-value" + } + ] + } + }, + "additionalProperties": false + }, + "person-patch-request-document": { + "required": [ + "data" + ], + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/person-data-in-patch-request" + } + }, + "additionalProperties": false + }, + "person-post-request-document": { + "required": [ + "data" + ], + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/person-data-in-post-request" + } + }, + "additionalProperties": false + }, + "person-primary-response-document": { + "required": [ + "data", + "links" + ], + "type": "object", + "properties": { + "meta": { + "type": "object", + "additionalProperties": {} + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapiObject" + }, + "links": { + "$ref": "#/components/schemas/linksInResourceDocument" + }, + "data": { + "$ref": "#/components/schemas/person-data-in-response" + } + }, + "additionalProperties": false + }, + "person-relationships-in-patch-request": { + "type": "object", + "properties": { + "assignedTodoItems": { + "$ref": "#/components/schemas/to-many-todoItem-request-data" + } + }, + "additionalProperties": false + }, + "person-relationships-in-post-request": { + "type": "object", + "properties": { + "assignedTodoItems": { + "$ref": "#/components/schemas/to-many-todoItem-request-data" + } + }, + "additionalProperties": false + }, + "person-relationships-in-response": { + "type": "object", + "properties": { + "assignedTodoItems": { + "$ref": "#/components/schemas/to-many-todoItem-response-data" + } + }, + "additionalProperties": false + }, + "person-secondary-response-document": { + "required": [ + "data", + "links" + ], + "type": "object", + "properties": { + "meta": { + "type": "object", + "additionalProperties": {} + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapiObject" + }, + "links": { + "$ref": "#/components/schemas/linksInResourceDocument" + }, + "data": { + "oneOf": [ + { + "$ref": "#/components/schemas/person-data-in-response" + }, + { + "$ref": "#/components/schemas/null-value" + } + ] + } + }, + "additionalProperties": false + }, + "tag-attributes-in-patch-request": { + "type": "object", + "properties": { + "name": { + "minLength": 1, + "type": "string" + } + }, + "additionalProperties": false + }, + "tag-attributes-in-post-request": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "name": { + "minLength": 1, + "type": "string" + } + }, + "additionalProperties": false + }, + "tag-attributes-in-response": { + "type": "object", + "properties": { + "name": { + "minLength": 1, + "type": "string" + } + }, + "additionalProperties": false + }, + "tag-collection-response-document": { + "required": [ + "data", + "links" + ], + "type": "object", + "properties": { + "meta": { + "type": "object", + "additionalProperties": {} + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapiObject" + }, + "links": { + "$ref": "#/components/schemas/linksInResourceCollectionDocument" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/tag-data-in-response" + } + } + }, + "additionalProperties": false + }, + "tag-data-in-patch-request": { + "required": [ + "id", + "type" + ], + "type": "object", + "properties": { + "type": { + "$ref": "#/components/schemas/tags-resource-type" + }, + "id": { + "type": "string" + }, + "attributes": { + "$ref": "#/components/schemas/tag-attributes-in-patch-request" + }, + "relationships": { + "$ref": "#/components/schemas/tag-relationships-in-patch-request" + } + }, + "additionalProperties": false + }, + "tag-data-in-post-request": { + "required": [ + "type" + ], + "type": "object", + "properties": { + "type": { + "$ref": "#/components/schemas/tags-resource-type" + }, + "attributes": { + "$ref": "#/components/schemas/tag-attributes-in-post-request" + }, + "relationships": { + "$ref": "#/components/schemas/tag-relationships-in-post-request" + } + }, + "additionalProperties": false + }, + "tag-data-in-response": { + "required": [ + "id", + "links", + "type" + ], + "type": "object", + "properties": { + "type": { + "$ref": "#/components/schemas/tags-resource-type" + }, + "id": { + "type": "string" + }, + "attributes": { + "$ref": "#/components/schemas/tag-attributes-in-response" + }, + "relationships": { + "$ref": "#/components/schemas/tag-relationships-in-response" + }, + "links": { + "$ref": "#/components/schemas/linksInResourceObject" + }, + "meta": { + "type": "object", + "additionalProperties": {} + } + }, + "additionalProperties": false + }, + "tag-identifier": { + "required": [ + "id", + "type" + ], + "type": "object", + "properties": { + "type": { + "$ref": "#/components/schemas/tags-resource-type" + }, + "id": { + "type": "string" + } + }, + "additionalProperties": false + }, + "tag-identifier-collection-response-document": { + "required": [ + "data", + "links" + ], + "type": "object", + "properties": { + "meta": { + "type": "object", + "additionalProperties": {} + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapiObject" + }, + "links": { + "$ref": "#/components/schemas/linksInResourceIdentifierCollectionDocument" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/tag-identifier" + } + } + }, + "additionalProperties": false + }, + "tag-patch-request-document": { + "required": [ + "data" + ], + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/tag-data-in-patch-request" + } + }, + "additionalProperties": false + }, + "tag-post-request-document": { + "required": [ + "data" + ], + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/tag-data-in-post-request" + } + }, + "additionalProperties": false + }, + "tag-primary-response-document": { + "required": [ + "data", + "links" + ], + "type": "object", + "properties": { + "meta": { + "type": "object", + "additionalProperties": {} + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapiObject" + }, + "links": { + "$ref": "#/components/schemas/linksInResourceDocument" + }, + "data": { + "$ref": "#/components/schemas/tag-data-in-response" + } + }, + "additionalProperties": false + }, + "tag-relationships-in-patch-request": { + "type": "object", + "properties": { + "todoItems": { + "$ref": "#/components/schemas/to-many-todoItem-request-data" + } + }, + "additionalProperties": false + }, + "tag-relationships-in-post-request": { + "type": "object", + "properties": { + "todoItems": { + "$ref": "#/components/schemas/to-many-todoItem-request-data" + } + }, + "additionalProperties": false + }, + "tag-relationships-in-response": { + "type": "object", + "properties": { + "todoItems": { + "$ref": "#/components/schemas/to-many-todoItem-response-data" + } + }, + "additionalProperties": false + }, + "tags-resource-type": { + "enum": [ + "tags" + ], + "type": "string" + }, + "to-many-tag-request-data": { + "required": [ + "data" + ], + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/tag-identifier" + } + } + }, + "additionalProperties": false + }, + "to-many-tag-response-data": { + "required": [ + "links" + ], + "type": "object", + "properties": { + "links": { + "$ref": "#/components/schemas/linksInRelationshipObject" + }, + "meta": { + "type": "object", + "additionalProperties": {} + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/tag-identifier" + } + } + }, + "additionalProperties": false + }, + "to-many-todoItem-request-data": { + "required": [ + "data" + ], + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/todoItem-identifier" + } + } + }, + "additionalProperties": false + }, + "to-many-todoItem-response-data": { + "required": [ + "links" + ], + "type": "object", + "properties": { + "links": { + "$ref": "#/components/schemas/linksInRelationshipObject" + }, + "meta": { + "type": "object", + "additionalProperties": {} + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/todoItem-identifier" + } + } + }, + "additionalProperties": false + }, + "to-one-person-request-data": { + "required": [ + "data" + ], + "type": "object", + "properties": { + "data": { + "oneOf": [ + { + "$ref": "#/components/schemas/person-identifier" + }, + { + "$ref": "#/components/schemas/null-value" + } + ] + } + }, + "additionalProperties": false + }, + "to-one-person-response-data": { + "required": [ + "links" + ], + "type": "object", + "properties": { + "links": { + "$ref": "#/components/schemas/linksInRelationshipObject" + }, + "meta": { + "type": "object", + "additionalProperties": {} + }, + "data": { + "oneOf": [ + { + "$ref": "#/components/schemas/person-identifier" + }, + { + "$ref": "#/components/schemas/null-value" + } + ] + } + }, + "additionalProperties": false + }, + "todoItem-attributes-in-patch-request": { + "type": "object", + "properties": { + "description": { + "type": "string", + "nullable": true + }, + "priority": { + "$ref": "#/components/schemas/todoItemPriority" + } + }, + "additionalProperties": false + }, + "todoItem-attributes-in-post-request": { + "type": "object", + "properties": { + "description": { + "type": "string", + "nullable": true + }, + "priority": { + "$ref": "#/components/schemas/todoItemPriority" + } + }, + "additionalProperties": false + }, + "todoItem-attributes-in-response": { + "type": "object", + "properties": { + "description": { + "type": "string", + "nullable": true + }, + "priority": { + "$ref": "#/components/schemas/todoItemPriority" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "modifiedAt": { + "type": "string", + "format": "date-time", + "nullable": true + } + }, + "additionalProperties": false + }, + "todoItem-collection-response-document": { + "required": [ + "data", + "links" + ], + "type": "object", + "properties": { + "meta": { + "type": "object", + "additionalProperties": {} + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapiObject" + }, + "links": { + "$ref": "#/components/schemas/linksInResourceCollectionDocument" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/todoItem-data-in-response" + } + } + }, + "additionalProperties": false + }, + "todoItem-data-in-patch-request": { + "required": [ + "id", + "type" + ], + "type": "object", + "properties": { + "type": { + "$ref": "#/components/schemas/todoItems-resource-type" + }, + "id": { + "type": "string" + }, + "attributes": { + "$ref": "#/components/schemas/todoItem-attributes-in-patch-request" + }, + "relationships": { + "$ref": "#/components/schemas/todoItem-relationships-in-patch-request" + } + }, + "additionalProperties": false + }, + "todoItem-data-in-post-request": { + "required": [ + "type" + ], + "type": "object", + "properties": { + "type": { + "$ref": "#/components/schemas/todoItems-resource-type" + }, + "attributes": { + "$ref": "#/components/schemas/todoItem-attributes-in-post-request" + }, + "relationships": { + "$ref": "#/components/schemas/todoItem-relationships-in-post-request" + } + }, + "additionalProperties": false + }, + "todoItem-data-in-response": { + "required": [ + "id", + "links", + "type" + ], + "type": "object", + "properties": { + "type": { + "$ref": "#/components/schemas/todoItems-resource-type" + }, + "id": { + "type": "string" + }, + "attributes": { + "$ref": "#/components/schemas/todoItem-attributes-in-response" + }, + "relationships": { + "$ref": "#/components/schemas/todoItem-relationships-in-response" + }, + "links": { + "$ref": "#/components/schemas/linksInResourceObject" + }, + "meta": { + "type": "object", + "additionalProperties": {} + } + }, + "additionalProperties": false + }, + "todoItem-identifier": { + "required": [ + "id", + "type" + ], + "type": "object", + "properties": { + "type": { + "$ref": "#/components/schemas/todoItems-resource-type" + }, + "id": { + "type": "string" + } + }, + "additionalProperties": false + }, + "todoItem-identifier-collection-response-document": { + "required": [ + "data", + "links" + ], + "type": "object", + "properties": { + "meta": { + "type": "object", + "additionalProperties": {} + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapiObject" + }, + "links": { + "$ref": "#/components/schemas/linksInResourceIdentifierCollectionDocument" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/todoItem-identifier" + } + } + }, + "additionalProperties": false + }, + "todoItem-patch-request-document": { + "required": [ + "data" + ], + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/todoItem-data-in-patch-request" + } + }, + "additionalProperties": false + }, + "todoItem-post-request-document": { + "required": [ + "data" + ], + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/todoItem-data-in-post-request" + } + }, + "additionalProperties": false + }, + "todoItem-primary-response-document": { + "required": [ + "data", + "links" + ], + "type": "object", + "properties": { + "meta": { + "type": "object", + "additionalProperties": {} + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapiObject" + }, + "links": { + "$ref": "#/components/schemas/linksInResourceDocument" + }, + "data": { + "$ref": "#/components/schemas/todoItem-data-in-response" + } + }, + "additionalProperties": false + }, + "todoItem-relationships-in-patch-request": { + "type": "object", + "properties": { + "owner": { + "$ref": "#/components/schemas/to-one-person-request-data" + }, + "assignee": { + "$ref": "#/components/schemas/to-one-person-request-data" + }, + "tags": { + "$ref": "#/components/schemas/to-many-tag-request-data" + } + }, + "additionalProperties": false + }, + "todoItem-relationships-in-post-request": { + "type": "object", + "properties": { + "owner": { + "$ref": "#/components/schemas/to-one-person-request-data" + }, + "assignee": { + "$ref": "#/components/schemas/to-one-person-request-data" + }, + "tags": { + "$ref": "#/components/schemas/to-many-tag-request-data" + } + }, + "additionalProperties": false + }, + "todoItem-relationships-in-response": { + "type": "object", + "properties": { + "owner": { + "$ref": "#/components/schemas/to-one-person-response-data" + }, + "assignee": { + "$ref": "#/components/schemas/to-one-person-response-data" + }, + "tags": { + "$ref": "#/components/schemas/to-many-tag-response-data" + } + }, + "additionalProperties": false + }, + "todoItemPriority": { + "enum": [ + "Low", + "Medium", + "High" + ], + "type": "string" + }, + "todoItems-resource-type": { + "enum": [ + "todoItems" + ], + "type": "string" + } + } + } +} diff --git a/src/Examples/JsonApiDotNetCoreExampleClient/Program.cs b/src/Examples/JsonApiDotNetCoreExampleClient/Program.cs new file mode 100644 index 0000000000..a4a8b6ff08 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExampleClient/Program.cs @@ -0,0 +1,31 @@ +using System; +using System.Net.Http; +using System.Threading.Tasks; + +namespace JsonApiDotNetCoreExampleClient +{ + internal static class Program + { + private const string BaseUrl = "http://localhost:14140"; + + private static async Task Main() + { + using var httpClient = new HttpClient(); + + ExampleApiClient exampleApiClient = new(BaseUrl, httpClient); + + try + { + const int nonExistingId = int.MaxValue; + await exampleApiClient.Delete_personAsync(nonExistingId); + } + catch (ApiException exception) + { + Console.WriteLine(exception.Response); + } + + Console.WriteLine("Press any key to close."); + Console.ReadKey(); + } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Client/ArgumentGuard.cs b/src/JsonApiDotNetCore.OpenApi.Client/ArgumentGuard.cs new file mode 100644 index 0000000000..e0531017af --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Client/ArgumentGuard.cs @@ -0,0 +1,21 @@ +using System; +using JetBrains.Annotations; + +#pragma warning disable AV1008 // Class should not be static + +namespace JsonApiDotNetCore.OpenApi.Client +{ + internal static class ArgumentGuard + { + [AssertionMethod] + [ContractAnnotation("value: null => halt")] + public static void NotNull([CanBeNull] [NoEnumeration] T value, [NotNull] [InvokerParameterName] string name) + where T : class + { + if (value is null) + { + throw new ArgumentNullException(name); + } + } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Client/AssemblyInfo.cs b/src/JsonApiDotNetCore.OpenApi.Client/AssemblyInfo.cs new file mode 100644 index 0000000000..2871965d43 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Client/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("OpenApiClientTests")] diff --git a/src/JsonApiDotNetCore.OpenApi.Client/IJsonApiClient.cs b/src/JsonApiDotNetCore.OpenApi.Client/IJsonApiClient.cs new file mode 100644 index 0000000000..1a78e31242 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Client/IJsonApiClient.cs @@ -0,0 +1,38 @@ +using System; +using System.Linq.Expressions; + +namespace JsonApiDotNetCore.OpenApi.Client +{ + public interface IJsonApiClient + { + /// + /// Ensures correct serialization of attributes in a POST/PATCH Resource request body. 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 null" vs "you didn't touch it, so it is null by default" when converting an instance to JSON. Therefore, calling this method + /// treats all attributes that contain their default value (null for reference types, 0 for integers, false for booleans, etc) as + /// omitted unless explicitly listed to include them using . + /// + /// + /// The request document instance for which this registration applies. + /// + /// + /// Optional. A list of expressions to indicate which properties to unconditionally 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. + /// + IDisposable RegisterAttributesForRequestDocument(TRequestDocument requestDocument, + params Expression>[] alwaysIncludedAttributeSelectors) + where TRequestDocument : class; + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Client/JsonApiClient.cs b/src/JsonApiDotNetCore.OpenApi.Client/JsonApiClient.cs new file mode 100644 index 0000000000..79be3be736 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Client/JsonApiClient.cs @@ -0,0 +1,223 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using JetBrains.Annotations; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; + +namespace JsonApiDotNetCore.OpenApi.Client +{ + /// + /// Base class to inherit auto-generated client from. Enables to mark fields to be explicitly included in a request body, even if they are null or + /// default. + /// + [PublicAPI] + public abstract class JsonApiClient : IJsonApiClient + { + private readonly JsonApiJsonConverter _jsonApiJsonConverter = new(); + + protected void SetSerializerSettingsForJsonApi(JsonSerializerSettings settings) + { + ArgumentGuard.NotNull(settings, nameof(settings)); + + settings.Converters.Add(_jsonApiJsonConverter); + } + + /// + public IDisposable RegisterAttributesForRequestDocument(TRequestDocument requestDocument, + params Expression>[] alwaysIncludedAttributeSelectors) + where TRequestDocument : class + { + ArgumentGuard.NotNull(requestDocument, nameof(requestDocument)); + + var attributeNames = new HashSet(); + + foreach (Expression> selector in alwaysIncludedAttributeSelectors) + { + if (RemoveConvert(selector.Body) is MemberExpression selectorBody) + { + attributeNames.Add(selectorBody.Member.Name); + } + else + { + throw new ArgumentException($"The expression '{selector}' should select a single property. For example: 'article => article.Title'."); + } + } + + _jsonApiJsonConverter.RegisterRequestDocument(requestDocument, new AttributeNamesContainer(attributeNames, typeof(TAttributesObject))); + + return new AttributesRegistrationScope(_jsonApiJsonConverter, requestDocument); + } + + private static Expression RemoveConvert(Expression expression) + { + Expression innerExpression = expression; + + while (true) + { + if (innerExpression is UnaryExpression { NodeType: ExpressionType.Convert } unaryExpression) + { + innerExpression = unaryExpression.Operand; + } + else + { + return innerExpression; + } + } + } + + private sealed class JsonApiJsonConverter : JsonConverter + { + private readonly Dictionary _alwaysIncludedAttributesPerRequestDocumentInstance = new(); + private readonly Dictionary> _requestDocumentInstancesPerRequestDocumentType = new(); + private bool _isSerializing; + + public override bool CanRead => false; + + public void RegisterRequestDocument(object requestDocument, AttributeNamesContainer attributes) + { + _alwaysIncludedAttributesPerRequestDocumentInstance[requestDocument] = attributes; + + Type requestDocumentType = requestDocument.GetType(); + + if (!_requestDocumentInstancesPerRequestDocumentType.ContainsKey(requestDocumentType)) + { + _requestDocumentInstancesPerRequestDocumentType[requestDocumentType] = new HashSet(); + } + + _requestDocumentInstancesPerRequestDocumentType[requestDocumentType].Add(requestDocument); + } + + public void RemoveAttributeRegistration(object requestDocument) + { + if (_alwaysIncludedAttributesPerRequestDocumentInstance.ContainsKey(requestDocument)) + { + _alwaysIncludedAttributesPerRequestDocumentInstance.Remove(requestDocument); + + Type requestDocumentType = requestDocument.GetType(); + _requestDocumentInstancesPerRequestDocumentType[requestDocumentType].Remove(requestDocument); + + if (!_requestDocumentInstancesPerRequestDocumentType[requestDocumentType].Any()) + { + _requestDocumentInstancesPerRequestDocumentType.Remove(requestDocumentType); + } + } + } + + public override bool CanConvert(Type objectType) + { + ArgumentGuard.NotNull(objectType, nameof(objectType)); + + return !_isSerializing && _requestDocumentInstancesPerRequestDocumentType.ContainsKey(objectType); + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + throw new Exception("This code should not be reachable."); + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + ArgumentGuard.NotNull(writer, nameof(writer)); + ArgumentGuard.NotNull(value, nameof(value)); + ArgumentGuard.NotNull(serializer, nameof(serializer)); + + if (_alwaysIncludedAttributesPerRequestDocumentInstance.ContainsKey(value)) + { + AttributeNamesContainer attributeNamesContainer = _alwaysIncludedAttributesPerRequestDocumentInstance[value]; + serializer.ContractResolver = new JsonApiDocumentContractResolver(attributeNamesContainer); + } + + try + { + _isSerializing = true; + serializer.Serialize(writer, value); + } + finally + { + _isSerializing = false; + } + } + } + + private sealed class AttributeNamesContainer + { + private readonly ISet _attributeNames; + private readonly Type _containerType; + + public AttributeNamesContainer(ISet attributeNames, Type containerType) + { + ArgumentGuard.NotNull(attributeNames, nameof(attributeNames)); + ArgumentGuard.NotNull(containerType, nameof(containerType)); + + _attributeNames = attributeNames; + _containerType = containerType; + } + + public bool ContainsAttribute(string name) + { + return _attributeNames.Contains(name); + } + + public bool ContainerMatchesType(Type type) + { + return _containerType == type; + } + } + + private sealed class AttributesRegistrationScope : IDisposable + { + private readonly JsonApiJsonConverter _jsonApiJsonConverter; + private readonly object _requestDocument; + + public AttributesRegistrationScope(JsonApiJsonConverter jsonApiJsonConverter, object requestDocument) + { + ArgumentGuard.NotNull(jsonApiJsonConverter, nameof(jsonApiJsonConverter)); + ArgumentGuard.NotNull(requestDocument, nameof(requestDocument)); + + _jsonApiJsonConverter = jsonApiJsonConverter; + _requestDocument = requestDocument; + } + + public void Dispose() + { + _jsonApiJsonConverter.RemoveAttributeRegistration(_requestDocument); + } + } + + private sealed class JsonApiDocumentContractResolver : DefaultContractResolver + { + private readonly AttributeNamesContainer _attributeNamesContainer; + + public JsonApiDocumentContractResolver(AttributeNamesContainer attributeNamesContainer) + { + ArgumentGuard.NotNull(attributeNamesContainer, nameof(attributeNamesContainer)); + + _attributeNamesContainer = attributeNamesContainer; + } + + protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization) + { + JsonProperty property = base.CreateProperty(member, memberSerialization); + + if (_attributeNamesContainer.ContainerMatchesType(property.DeclaringType)) + { + if (_attributeNamesContainer.ContainsAttribute(property.UnderlyingName)) + { + property.NullValueHandling = NullValueHandling.Include; + property.DefaultValueHandling = DefaultValueHandling.Include; + } + else + { + property.NullValueHandling = NullValueHandling.Ignore; + property.DefaultValueHandling = DefaultValueHandling.Ignore; + } + } + + return property; + } + } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Client/JsonApiDotNetCore.OpenApi.Client.csproj b/src/JsonApiDotNetCore.OpenApi.Client/JsonApiDotNetCore.OpenApi.Client.csproj new file mode 100644 index 0000000000..8fdd3d9021 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Client/JsonApiDotNetCore.OpenApi.Client.csproj @@ -0,0 +1,29 @@ + + + $(JsonApiDotNetCoreVersionPrefix) + $(NetCoreAppVersion) + true + + + + jsonapidotnetcore;jsonapi;json:api;dotnet;asp.net;openapi;swagger;client;nswag + Provides support for OpenAPI generated clients in sending partial POST/PATCH requests against JSON:API endpoints. + json-api-dotnet + https://www.jsonapi.net/ + MIT + false + true + true + embedded + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + diff --git a/src/JsonApiDotNetCore.OpenApi/ActionDescriptorExtensions.cs b/src/JsonApiDotNetCore.OpenApi/ActionDescriptorExtensions.cs new file mode 100644 index 0000000000..e0d4ae4f35 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/ActionDescriptorExtensions.cs @@ -0,0 +1,39 @@ +using System.Linq; +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 +{ + internal static class ActionDescriptorExtensions + { + public static MethodInfo GetActionMethod(this ActionDescriptor descriptor) + { + ArgumentGuard.NotNull(descriptor, nameof(descriptor)); + + return ((ControllerActionDescriptor)descriptor).MethodInfo; + } + + public static TFilterMetaData GetFilterMetadata(this ActionDescriptor descriptor) + where TFilterMetaData : IFilterMetadata + { + ArgumentGuard.NotNull(descriptor, nameof(descriptor)); + + IFilterMetadata filterMetadata = descriptor.FilterDescriptors.Select(filterDescriptor => filterDescriptor.Filter) + .FirstOrDefault(filter => filter is TFilterMetaData); + + return (TFilterMetaData)filterMetadata; + } + + public static ControllerParameterDescriptor GetBodyParameterDescriptor(this ActionDescriptor descriptor) + { + ArgumentGuard.NotNull(descriptor, nameof(descriptor)); + + return (ControllerParameterDescriptor)descriptor.Parameters.FirstOrDefault(parameterDescriptor => + // ReSharper disable once ConstantConditionalAccessQualifier Motivation: see https://github.com/dotnet/aspnetcore/issues/32538 + parameterDescriptor.BindingInfo?.BindingSource == BindingSource.Body); + } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiActionDescriptorCollectionProvider.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiActionDescriptorCollectionProvider.cs new file mode 100644 index 0000000000..3821ad5264 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiActionDescriptorCollectionProvider.cs @@ -0,0 +1,195 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.OpenApi.JsonApiMetadata; +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; + +namespace JsonApiDotNetCore.OpenApi +{ + /// + /// Adds JsonApiDotNetCore metadata to s if available. This translates to updating response types in + /// and performing an expansion for secondary and relationship endpoints (eg + /// /article/{id}/{relationshipName} -> /article/{id}/author, /article/{id}/revisions, etc). + /// + internal sealed class JsonApiActionDescriptorCollectionProvider : IActionDescriptorCollectionProvider + { + private readonly IActionDescriptorCollectionProvider _defaultProvider; + private readonly JsonApiEndpointMetadataProvider _jsonApiEndpointMetadataProvider; + + public ActionDescriptorCollection ActionDescriptors => GetActionDescriptors(); + + public JsonApiActionDescriptorCollectionProvider(IResourceGraph resourceGraph, IControllerResourceMapping controllerResourceMapping, + IActionDescriptorCollectionProvider defaultProvider) + { + ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); + ArgumentGuard.NotNull(controllerResourceMapping, nameof(controllerResourceMapping)); + ArgumentGuard.NotNull(defaultProvider, nameof(defaultProvider)); + + _defaultProvider = defaultProvider; + _jsonApiEndpointMetadataProvider = new JsonApiEndpointMetadataProvider(resourceGraph, controllerResourceMapping); + } + + private ActionDescriptorCollection GetActionDescriptors() + { + List newDescriptors = _defaultProvider.ActionDescriptors.Items.ToList(); + List endpoints = newDescriptors.Where(IsVisibleJsonApiEndpoint).ToList(); + + foreach (ActionDescriptor endpoint in endpoints) + { + JsonApiEndpointMetadataContainer endpointMetadataContainer = _jsonApiEndpointMetadataProvider.Get(endpoint.GetActionMethod()); + + List replacementDescriptorsForEndpoint = new(); + replacementDescriptorsForEndpoint.AddRange(AddJsonApiMetadataToAction(endpoint, endpointMetadataContainer.RequestMetadata)); + replacementDescriptorsForEndpoint.AddRange(AddJsonApiMetadataToAction(endpoint, endpointMetadataContainer.ResponseMetadata)); + + if (replacementDescriptorsForEndpoint.Any()) + { + newDescriptors.InsertRange(newDescriptors.IndexOf(endpoint) - 1, replacementDescriptorsForEndpoint); + newDescriptors.Remove(endpoint); + } + } + + int descriptorVersion = _defaultProvider.ActionDescriptors.Version; + return new ActionDescriptorCollection(newDescriptors.AsReadOnly(), descriptorVersion); + } + + private static bool IsVisibleJsonApiEndpoint(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)); + } + + private static IList AddJsonApiMetadataToAction(ActionDescriptor endpoint, IJsonApiEndpointMetadata jsonApiEndpointMetadata) + { + switch (jsonApiEndpointMetadata) + { + case PrimaryResponseMetadata primaryMetadata: + { + UpdateProducesResponseTypeAttribute(endpoint, primaryMetadata.Type); + return Array.Empty(); + } + case PrimaryRequestMetadata primaryMetadata: + { + UpdateBodyParameterDescriptor(endpoint, primaryMetadata.Type); + return Array.Empty(); + } + case ExpansibleEndpointMetadata expansibleMetadata + when expansibleMetadata is RelationshipResponseMetadata || expansibleMetadata is SecondaryResponseMetadata: + { + return Expand(endpoint, expansibleMetadata, + (expandedEndpoint, relationshipType, _) => UpdateProducesResponseTypeAttribute(expandedEndpoint, relationshipType)); + } + case ExpansibleEndpointMetadata expansibleMetadata when expansibleMetadata is RelationshipRequestMetadata: + { + return Expand(endpoint, expansibleMetadata, UpdateBodyParameterDescriptor); + } + default: + { + return Array.Empty(); + } + } + } + + private static void UpdateProducesResponseTypeAttribute(ActionDescriptor endpoint, Type responseTypeToSet) + { + if (ProducesJsonApiResponseBody(endpoint)) + { + var producesResponse = endpoint.GetFilterMetadata(); + producesResponse.Type = responseTypeToSet; + } + } + + private static bool ProducesJsonApiResponseBody(ActionDescriptor endpoint) + { + var produces = endpoint.GetFilterMetadata(); + + return produces != null && produces.ContentTypes.Any(contentType => contentType == HeaderConstants.MediaType); + } + + private static IList Expand(ActionDescriptor genericEndpoint, ExpansibleEndpointMetadata metadata, + Action expansionCallback) + { + var expansion = new List(); + + foreach ((string relationshipName, Type relationshipType) in metadata.ExpansionElements) + { + ActionDescriptor expandedEndpoint = Clone(genericEndpoint); + RemovePathParameter(expandedEndpoint.Parameters, JsonApiPathParameter.RelationshipName); + ExpandTemplate(expandedEndpoint.AttributeRouteInfo, relationshipName); + + expansionCallback(expandedEndpoint, relationshipType, relationshipName); + + expansion.Add(expandedEndpoint); + } + + return expansion; + } + + private static void UpdateBodyParameterDescriptor(ActionDescriptor endpoint, Type bodyType, string parameterName = null) + { + ControllerParameterDescriptor requestBodyDescriptor = endpoint.GetBodyParameterDescriptor(); + requestBodyDescriptor.ParameterType = bodyType; + ParameterInfo replacementParameterInfo = requestBodyDescriptor.ParameterInfo.WithParameterType(bodyType); + + if (parameterName != null) + { + replacementParameterInfo = replacementParameterInfo.WithName(parameterName); + } + + requestBodyDescriptor.ParameterInfo = replacementParameterInfo; + } + + private static ActionDescriptor Clone(ActionDescriptor descriptor) + { + var clonedDescriptor = (ActionDescriptor)descriptor.MemberwiseClone(); + + clonedDescriptor.AttributeRouteInfo = (AttributeRouteInfo)descriptor.AttributeRouteInfo.MemberwiseClone(); + + clonedDescriptor.FilterDescriptors = new List(); + + foreach (FilterDescriptor filter in descriptor.FilterDescriptors) + { + clonedDescriptor.FilterDescriptors.Add(Clone(filter)); + } + + clonedDescriptor.Parameters = new List(); + + foreach (ParameterDescriptor parameter in descriptor.Parameters) + { + clonedDescriptor.Parameters.Add((ParameterDescriptor)parameter.MemberwiseClone()); + } + + return clonedDescriptor; + } + + private static FilterDescriptor Clone(FilterDescriptor descriptor) + { + var clonedFilter = (IFilterMetadata)descriptor.Filter.MemberwiseClone(); + + return new FilterDescriptor(clonedFilter, descriptor.Scope) + { + Order = descriptor.Order + }; + } + + private static void RemovePathParameter(ICollection parameters, string parameterName) + { + ParameterDescriptor relationshipName = parameters.Single(parameterDescriptor => parameterDescriptor.Name == parameterName); + + parameters.Remove(relationshipName); + } + + private static void ExpandTemplate(AttributeRouteInfo route, string expansionParameter) + { + route.Template = route.Template!.Replace(JsonApiRoutingTemplate.RelationshipNameUrlPlaceholder, expansionParameter); + } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiEndpoint.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiEndpoint.cs new file mode 100644 index 0000000000..c3ac0cbb95 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiEndpoint.cs @@ -0,0 +1,18 @@ +namespace JsonApiDotNetCore.OpenApi +{ + internal enum JsonApiEndpoint + { + GetCollection, + GetSingle, + GetSecondary, + GetRelationship, + Post, + PostRelationship, + Patch, + PatchRelationship, +#pragma warning disable AV1711 // Name members and local functions similarly to members of .NET Framework classes + Delete, +#pragma warning restore AV1711 // Name members and local functions similarly to members of .NET Framework classes + DeleteRelationship + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/EndpointResolver.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/EndpointResolver.cs new file mode 100644 index 0000000000..fae0d1fbc6 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/EndpointResolver.cs @@ -0,0 +1,70 @@ +using System.Linq; +using System.Reflection; +using JsonApiDotNetCore.Controllers; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; + +namespace JsonApiDotNetCore.OpenApi.JsonApiMetadata +{ + internal sealed class EndpointResolver + { + public JsonApiEndpoint? Get(MethodInfo controllerAction) + { + ArgumentGuard.NotNull(controllerAction, nameof(controllerAction)); + + // This is a temporary work-around to prevent the JsonApiDotNetCoreExample project from crashing upon startup. + if (!IsJsonApiController(controllerAction) || IsOperationsController(controllerAction)) + { + return null; + } + + HttpMethodAttribute method = controllerAction.GetCustomAttributes(true).OfType().FirstOrDefault(); + + return ResolveJsonApiEndpoint(method); + } + + private static bool IsJsonApiController(MethodInfo controllerAction) + { + return typeof(CoreJsonApiController).IsAssignableFrom(controllerAction.ReflectedType); + } + + private static bool IsOperationsController(MethodInfo controllerAction) + { + return typeof(BaseJsonApiOperationsController).IsAssignableFrom(controllerAction.ReflectedType); + } + + private static JsonApiEndpoint? ResolveJsonApiEndpoint(HttpMethodAttribute httpMethod) + { + return httpMethod switch + { + HttpGetAttribute attr => attr.Template switch + { + null => JsonApiEndpoint.GetCollection, + JsonApiRoutingTemplate.PrimaryEndpoint => JsonApiEndpoint.GetSingle, + JsonApiRoutingTemplate.SecondaryEndpoint => JsonApiEndpoint.GetSecondary, + JsonApiRoutingTemplate.RelationshipEndpoint => JsonApiEndpoint.GetRelationship, + _ => null + }, + HttpPostAttribute attr => attr.Template switch + { + null => JsonApiEndpoint.Post, + JsonApiRoutingTemplate.RelationshipEndpoint => JsonApiEndpoint.PostRelationship, + _ => null + }, + HttpPatchAttribute attr => attr.Template switch + { + JsonApiRoutingTemplate.PrimaryEndpoint => JsonApiEndpoint.Patch, + JsonApiRoutingTemplate.RelationshipEndpoint => JsonApiEndpoint.PatchRelationship, + _ => null + }, + HttpDeleteAttribute attr => attr.Template switch + { + JsonApiRoutingTemplate.PrimaryEndpoint => JsonApiEndpoint.Delete, + JsonApiRoutingTemplate.RelationshipEndpoint => JsonApiEndpoint.DeleteRelationship, + _ => null + }, + _ => null + }; + } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/ExpansibleEndpointMetadata.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/ExpansibleEndpointMetadata.cs new file mode 100644 index 0000000000..279abddf9c --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/ExpansibleEndpointMetadata.cs @@ -0,0 +1,10 @@ +using System; +using System.Collections.Generic; + +namespace JsonApiDotNetCore.OpenApi.JsonApiMetadata +{ + internal abstract class ExpansibleEndpointMetadata + { + public abstract IDictionary ExpansionElements { get; } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/IJsonApiEndpointMetadata.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/IJsonApiEndpointMetadata.cs new file mode 100644 index 0000000000..a12943366f --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/IJsonApiEndpointMetadata.cs @@ -0,0 +1,6 @@ +namespace JsonApiDotNetCore.OpenApi.JsonApiMetadata +{ + internal interface IJsonApiEndpointMetadata + { + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/IJsonApiRequestMetadata.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/IJsonApiRequestMetadata.cs new file mode 100644 index 0000000000..31a5c397be --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/IJsonApiRequestMetadata.cs @@ -0,0 +1,6 @@ +namespace JsonApiDotNetCore.OpenApi.JsonApiMetadata +{ + internal interface IJsonApiRequestMetadata : IJsonApiEndpointMetadata + { + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/IJsonApiResponseMetadata.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/IJsonApiResponseMetadata.cs new file mode 100644 index 0000000000..1572daab59 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/IJsonApiResponseMetadata.cs @@ -0,0 +1,6 @@ +namespace JsonApiDotNetCore.OpenApi.JsonApiMetadata +{ + internal interface IJsonApiResponseMetadata : IJsonApiEndpointMetadata + { + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/JsonApiEndpointMetadataContainer.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/JsonApiEndpointMetadataContainer.cs new file mode 100644 index 0000000000..76a22595bd --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/JsonApiEndpointMetadataContainer.cs @@ -0,0 +1,12 @@ +namespace JsonApiDotNetCore.OpenApi.JsonApiMetadata +{ + /// + /// Metadata available at runtime about a JsonApiDotNetCore endpoint. + /// + internal sealed class JsonApiEndpointMetadataContainer + { + public IJsonApiRequestMetadata RequestMetadata { get; init; } + + public IJsonApiResponseMetadata ResponseMetadata { get; init; } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/JsonApiEndpointMetadataProvider.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/JsonApiEndpointMetadataProvider.cs new file mode 100644 index 0000000000..6a2769d829 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/JsonApiEndpointMetadataProvider.cs @@ -0,0 +1,187 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.Documents; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.RelationshipData; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.OpenApi.JsonApiMetadata +{ + /// + /// Provides JsonApiDotNetCore related metadata for an ASP.NET controller action that can only be computed from the at + /// runtime. + /// + internal sealed class JsonApiEndpointMetadataProvider + { + private readonly IResourceGraph _resourceGraph; + private readonly IControllerResourceMapping _controllerResourceMapping; + private readonly EndpointResolver _endpointResolver = new(); + + public JsonApiEndpointMetadataProvider(IResourceGraph resourceGraph, IControllerResourceMapping controllerResourceMapping) + { + ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); + ArgumentGuard.NotNull(controllerResourceMapping, nameof(controllerResourceMapping)); + + _resourceGraph = resourceGraph; + _controllerResourceMapping = controllerResourceMapping; + } + + public JsonApiEndpointMetadataContainer Get(MethodInfo controllerAction) + { + ArgumentGuard.NotNull(controllerAction, nameof(controllerAction)); + + JsonApiEndpoint? endpoint = _endpointResolver.Get(controllerAction); + + if (endpoint == null) + { + throw new NotSupportedException($"Unable to provide metadata for non-JsonApiDotNetCore endpoint '{controllerAction.ReflectedType!.FullName}'."); + } + + Type primaryResourceType = _controllerResourceMapping.GetResourceTypeForController(controllerAction.ReflectedType); + + return new JsonApiEndpointMetadataContainer + { + RequestMetadata = GetRequestMetadata(endpoint.Value, primaryResourceType), + ResponseMetadata = GetResponseMetadata(endpoint.Value, primaryResourceType) + }; + } + + private IJsonApiRequestMetadata GetRequestMetadata(JsonApiEndpoint endpoint, Type primaryResourceType) + { + switch (endpoint) + { + case JsonApiEndpoint.Post: + { + return GetPostRequestMetadata(primaryResourceType); + } + case JsonApiEndpoint.Patch: + { + return GetPatchRequestMetadata(primaryResourceType); + } + case JsonApiEndpoint.PostRelationship: + case JsonApiEndpoint.PatchRelationship: + case JsonApiEndpoint.DeleteRelationship: + { + return GetRelationshipRequestMetadata(primaryResourceType, endpoint != JsonApiEndpoint.PatchRelationship); + } + default: + { + return null; + } + } + } + + private static PrimaryRequestMetadata GetPostRequestMetadata(Type primaryResourceType) + { + return new() + { + Type = typeof(ResourcePostRequestDocument<>).MakeGenericType(primaryResourceType) + }; + } + + private static PrimaryRequestMetadata GetPatchRequestMetadata(Type primaryResourceType) + { + return new() + { + Type = typeof(ResourcePatchRequestDocument<>).MakeGenericType(primaryResourceType) + }; + } + + private RelationshipRequestMetadata GetRelationshipRequestMetadata(Type primaryResourceType, bool ignoreHasOneRelationships) + { + IEnumerable relationships = _resourceGraph.GetResourceContext(primaryResourceType).Relationships; + + if (ignoreHasOneRelationships) + { + relationships = relationships.OfType(); + } + + IDictionary resourceTypesByRelationshipName = relationships.ToDictionary(relationship => relationship.PublicName, + relationship => relationship is HasManyAttribute + ? typeof(ToManyRelationshipRequestData<>).MakeGenericType(relationship.RightType) + : typeof(ToOneRelationshipRequestData<>).MakeGenericType(relationship.RightType)); + + return new RelationshipRequestMetadata + { + RequestBodyTypeByRelationshipName = resourceTypesByRelationshipName + }; + } + + private IJsonApiResponseMetadata GetResponseMetadata(JsonApiEndpoint endpoint, Type primaryResourceType) + { + switch (endpoint) + { + case JsonApiEndpoint.GetCollection: + case JsonApiEndpoint.GetSingle: + case JsonApiEndpoint.Post: + case JsonApiEndpoint.Patch: + { + return GetPrimaryResponseMetadata(primaryResourceType, endpoint == JsonApiEndpoint.GetCollection); + } + case JsonApiEndpoint.GetSecondary: + { + return GetSecondaryResponseMetadata(primaryResourceType); + } + case JsonApiEndpoint.GetRelationship: + { + return GetRelationshipResponseMetadata(primaryResourceType); + } + default: + { + return null; + } + } + } + + private static PrimaryResponseMetadata GetPrimaryResponseMetadata(Type primaryResourceType, bool endpointReturnsCollection) + { + Type documentType = endpointReturnsCollection ? typeof(ResourceCollectionResponseDocument<>) : typeof(PrimaryResourceResponseDocument<>); + + return new PrimaryResponseMetadata + { + Type = documentType.MakeGenericType(primaryResourceType) + }; + } + + private SecondaryResponseMetadata GetSecondaryResponseMetadata(Type primaryResourceType) + { + IDictionary responseTypesByRelationshipName = GetMetadataByRelationshipName(primaryResourceType, relationship => + { + Type documentType = relationship is HasManyAttribute + ? typeof(ResourceCollectionResponseDocument<>) + : typeof(SecondaryResourceResponseDocument<>); + + return documentType.MakeGenericType(relationship.RightType); + }); + + return new SecondaryResponseMetadata + { + ResponseTypesByRelationshipName = responseTypesByRelationshipName + }; + } + + private IDictionary GetMetadataByRelationshipName(Type primaryResourceType, + Func extractRelationshipMetadataCallback) + { + IReadOnlyCollection relationships = _resourceGraph.GetResourceContext(primaryResourceType).Relationships; + + return relationships.ToDictionary(relationship => relationship.PublicName, extractRelationshipMetadataCallback); + } + + private RelationshipResponseMetadata GetRelationshipResponseMetadata(Type primaryResourceType) + { + IDictionary responseTypesByRelationshipName = GetMetadataByRelationshipName(primaryResourceType, + relationship => relationship is HasManyAttribute + ? typeof(ResourceIdentifierCollectionResponseDocument<>).MakeGenericType(relationship.RightType) + : typeof(ResourceIdentifierResponseDocument<>).MakeGenericType(relationship.RightType)); + + return new RelationshipResponseMetadata + { + ResponseTypesByRelationshipName = responseTypesByRelationshipName + }; + } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/PrimaryRequestMetadata.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/PrimaryRequestMetadata.cs new file mode 100644 index 0000000000..c217aefcb3 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/PrimaryRequestMetadata.cs @@ -0,0 +1,9 @@ +using System; + +namespace JsonApiDotNetCore.OpenApi.JsonApiMetadata +{ + internal sealed class PrimaryRequestMetadata : IJsonApiRequestMetadata + { + public Type Type { get; init; } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/PrimaryResponseMetadata.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/PrimaryResponseMetadata.cs new file mode 100644 index 0000000000..13647ec857 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/PrimaryResponseMetadata.cs @@ -0,0 +1,9 @@ +using System; + +namespace JsonApiDotNetCore.OpenApi.JsonApiMetadata +{ + internal sealed class PrimaryResponseMetadata : IJsonApiResponseMetadata + { + public Type Type { get; init; } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/RelationshipRequestMetadata.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/RelationshipRequestMetadata.cs new file mode 100644 index 0000000000..9156803a3b --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/RelationshipRequestMetadata.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; + +namespace JsonApiDotNetCore.OpenApi.JsonApiMetadata +{ + internal sealed class RelationshipRequestMetadata : ExpansibleEndpointMetadata, IJsonApiRequestMetadata + { + public IDictionary RequestBodyTypeByRelationshipName { get; init; } + + public override IDictionary ExpansionElements => RequestBodyTypeByRelationshipName; + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/RelationshipResponseMetadata.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/RelationshipResponseMetadata.cs new file mode 100644 index 0000000000..28b9cd2df1 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/RelationshipResponseMetadata.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; + +namespace JsonApiDotNetCore.OpenApi.JsonApiMetadata +{ + internal sealed class RelationshipResponseMetadata : ExpansibleEndpointMetadata, IJsonApiResponseMetadata + { + public IDictionary ResponseTypesByRelationshipName { get; init; } + + public override IDictionary ExpansionElements => ResponseTypesByRelationshipName; + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/SecondaryResponseMetadata.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/SecondaryResponseMetadata.cs new file mode 100644 index 0000000000..45e5f4e0ab --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/SecondaryResponseMetadata.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; + +namespace JsonApiDotNetCore.OpenApi.JsonApiMetadata +{ + internal sealed class SecondaryResponseMetadata : ExpansibleEndpointMetadata, IJsonApiResponseMetadata + { + public IDictionary ResponseTypesByRelationshipName { get; init; } + + public override IDictionary ExpansionElements => ResponseTypesByRelationshipName; + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjectPropertyName.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjectPropertyName.cs new file mode 100644 index 0000000000..c9ff79f9d6 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjectPropertyName.cs @@ -0,0 +1,19 @@ +#pragma warning disable AV1008 // Class should not be static + +namespace JsonApiDotNetCore.OpenApi +{ + internal static class JsonApiObjectPropertyName + { + public const string Type = "type"; + public const string Id = "id"; + public const string Data = "data"; + public const string AttributesObject = "attributes"; + public const string RelationshipsObject = "relationships"; + public const string MetaObject = "meta"; + public const string LinksObject = "links"; + public const string JsonapiObject = "jsonapi"; + public const string JsonapiObjectVersion = "version"; + public const string JsonapiObjectExt = "ext"; + public const string JsonapiObjectProfile = "profile"; + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/PrimaryResourceResponseDocument.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/PrimaryResourceResponseDocument.cs new file mode 100644 index 0000000000..931f787808 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/PrimaryResourceResponseDocument.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.Links; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.JsonApiObjects.Documents +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + internal sealed class PrimaryResourceResponseDocument : SingleData> + where TResource : IIdentifiable + { + public IDictionary Meta { get; set; } + + public JsonapiObject Jsonapi { get; set; } + + [Required] + public LinksInResourceDocument Links { get; set; } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/ResourceCollectionResponseDocument.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/ResourceCollectionResponseDocument.cs new file mode 100644 index 0000000000..a6a2377bdc --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/ResourceCollectionResponseDocument.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.Links; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.JsonApiObjects.Documents +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + internal sealed class ResourceCollectionResponseDocument : ManyData> + where TResource : IIdentifiable + { + public IDictionary Meta { get; set; } + + public JsonapiObject Jsonapi { get; set; } + + [Required] + public LinksInResourceCollectionDocument Links { get; set; } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/ResourceIdentifierCollectionResponseDocument.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/ResourceIdentifierCollectionResponseDocument.cs new file mode 100644 index 0000000000..85e9b9e7f0 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/ResourceIdentifierCollectionResponseDocument.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.Links; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.JsonApiObjects.Documents +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + internal sealed class ResourceIdentifierCollectionResponseDocument : ManyData> + where TResource : IIdentifiable + { + public IDictionary Meta { get; set; } + + public JsonapiObject Jsonapi { get; set; } + + [Required] + public LinksInResourceIdentifierCollectionDocument Links { get; set; } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/ResourceIdentifierResponseDocument.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/ResourceIdentifierResponseDocument.cs new file mode 100644 index 0000000000..b1b4299191 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/ResourceIdentifierResponseDocument.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.Links; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.JsonApiObjects.Documents +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + internal sealed class ResourceIdentifierResponseDocument : SingleData> + where TResource : IIdentifiable + { + public IDictionary Meta { get; set; } + + public JsonapiObject Jsonapi { get; set; } + + [Required] + public LinksInResourceIdentifierDocument Links { get; set; } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/ResourcePatchRequestDocument.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/ResourcePatchRequestDocument.cs new file mode 100644 index 0000000000..7246695ccd --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/ResourcePatchRequestDocument.cs @@ -0,0 +1,12 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.JsonApiObjects.Documents +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + internal sealed class ResourcePatchRequestDocument : SingleData> + where TResource : IIdentifiable + { + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/ResourcePostRequestDocument.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/ResourcePostRequestDocument.cs new file mode 100644 index 0000000000..034863b5cf --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/ResourcePostRequestDocument.cs @@ -0,0 +1,12 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.JsonApiObjects.Documents +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + internal sealed class ResourcePostRequestDocument : SingleData> + where TResource : IIdentifiable + { + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/SecondaryResourceResponseDocument.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/SecondaryResourceResponseDocument.cs new file mode 100644 index 0000000000..876e290565 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/SecondaryResourceResponseDocument.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.Links; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.JsonApiObjects.Documents +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + internal sealed class SecondaryResourceResponseDocument : SingleData> + where TResource : IIdentifiable + { + public IDictionary Meta { get; set; } + + public JsonapiObject Jsonapi { get; set; } + + [Required] + public LinksInResourceDocument Links { get; set; } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/JsonapiObject.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/JsonapiObject.cs new file mode 100644 index 0000000000..6f7d7bd4fa --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/JsonapiObject.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.OpenApi.JsonApiObjects +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + internal sealed class JsonapiObject + { + public string Version { get; set; } + + public ICollection Ext { get; set; } + + public ICollection Profile { get; set; } + + public IDictionary Meta { get; set; } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Links/LinksInRelationshipObject.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Links/LinksInRelationshipObject.cs new file mode 100644 index 0000000000..8b1ca67162 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Links/LinksInRelationshipObject.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.OpenApi.JsonApiObjects.Links +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + internal sealed class LinksInRelationshipObject + { + [Required] + public string Self { get; set; } + + [Required] + public string Related { get; set; } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Links/LinksInResourceCollectionDocument.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Links/LinksInResourceCollectionDocument.cs new file mode 100644 index 0000000000..84d5e37aa0 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Links/LinksInResourceCollectionDocument.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.OpenApi.JsonApiObjects.Links +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + internal sealed class LinksInResourceCollectionDocument + { + [Required] + public string Self { get; set; } + + public string Describedby { get; set; } + + [Required] + public string First { get; set; } + + public string Last { get; set; } + + public string Prev { get; set; } + + public string Next { get; set; } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Links/LinksInResourceDocument.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Links/LinksInResourceDocument.cs new file mode 100644 index 0000000000..f2686c12b3 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Links/LinksInResourceDocument.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.OpenApi.JsonApiObjects.Links +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + internal sealed class LinksInResourceDocument + { + [Required] + public string Self { get; set; } + + public string Describedby { get; set; } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Links/LinksInResourceIdentifierCollectionDocument.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Links/LinksInResourceIdentifierCollectionDocument.cs new file mode 100644 index 0000000000..8596f60156 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Links/LinksInResourceIdentifierCollectionDocument.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.OpenApi.JsonApiObjects.Links +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + internal sealed class LinksInResourceIdentifierCollectionDocument + { + [Required] + public string Self { get; set; } + + public string Describedby { get; set; } + + [Required] + public string Related { get; set; } + + [Required] + public string First { get; set; } + + public string Last { get; set; } + + public string Prev { get; set; } + + public string Next { get; set; } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Links/LinksInResourceIdentifierDocument.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Links/LinksInResourceIdentifierDocument.cs new file mode 100644 index 0000000000..88d568f648 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Links/LinksInResourceIdentifierDocument.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.OpenApi.JsonApiObjects.Links +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + internal sealed class LinksInResourceIdentifierDocument + { + [Required] + public string Self { get; set; } + + public string Describedby { get; set; } + + [Required] + public string Related { get; set; } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Links/LinksInResourceObject.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Links/LinksInResourceObject.cs new file mode 100644 index 0000000000..10313617cf --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Links/LinksInResourceObject.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.OpenApi.JsonApiObjects.Links +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + internal sealed class LinksInResourceObject + { + [Required] + public string Self { get; set; } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/ManyData.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/ManyData.cs new file mode 100644 index 0000000000..b6253f5142 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/ManyData.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.ResourceObjects; + +namespace JsonApiDotNetCore.OpenApi.JsonApiObjects +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + internal abstract class ManyData + where TData : ResourceIdentifierObject + { + [Required] + public ICollection Data { get; set; } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/RelationshipData/ToManyRelationshipRequestData.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/RelationshipData/ToManyRelationshipRequestData.cs new file mode 100644 index 0000000000..0d29dd496a --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/RelationshipData/ToManyRelationshipRequestData.cs @@ -0,0 +1,12 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.JsonApiObjects.RelationshipData +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + internal sealed class ToManyRelationshipRequestData : ManyData> + where TResource : IIdentifiable + { + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/RelationshipData/ToManyRelationshipResponseData.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/RelationshipData/ToManyRelationshipResponseData.cs new file mode 100644 index 0000000000..a6f10e9e9a --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/RelationshipData/ToManyRelationshipResponseData.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.Links; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.JsonApiObjects.RelationshipData +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + internal sealed class ToManyRelationshipResponseData : ManyData> + where TResource : IIdentifiable + { + [Required] + public LinksInRelationshipObject Links { get; set; } + + public IDictionary Meta { get; set; } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/RelationshipData/ToOneRelationshipRequestData.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/RelationshipData/ToOneRelationshipRequestData.cs new file mode 100644 index 0000000000..1526768c31 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/RelationshipData/ToOneRelationshipRequestData.cs @@ -0,0 +1,12 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.JsonApiObjects.RelationshipData +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + internal sealed class ToOneRelationshipRequestData : SingleData> + where TResource : IIdentifiable + { + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/RelationshipData/ToOneRelationshipResponseData.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/RelationshipData/ToOneRelationshipResponseData.cs new file mode 100644 index 0000000000..5edc6b3450 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/RelationshipData/ToOneRelationshipResponseData.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.Links; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.JsonApiObjects.RelationshipData +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + internal sealed class ToOneRelationshipResponseData : SingleData> + where TResource : IIdentifiable + { + [Required] + public LinksInRelationshipObject Links { get; set; } + + public IDictionary Meta { get; set; } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/ResourceObjects/ResourceIdentifierObject.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/ResourceObjects/ResourceIdentifierObject.cs new file mode 100644 index 0000000000..e2f6d08136 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/ResourceObjects/ResourceIdentifierObject.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.JsonApiObjects.ResourceObjects +{ + // ReSharper disable once UnusedTypeParameter + internal class ResourceIdentifierObject : ResourceIdentifierObject + where TResource : IIdentifiable + { + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + internal class ResourceIdentifierObject + { + [Required] + public string Type { get; set; } + + [Required] + public string Id { get; set; } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/ResourceObjects/ResourceObject.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/ResourceObjects/ResourceObject.cs new file mode 100644 index 0000000000..80366a8277 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/ResourceObjects/ResourceObject.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.JsonApiObjects.ResourceObjects +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + internal abstract class ResourceObject : ResourceIdentifierObject + where TResource : IIdentifiable + { + public IDictionary Attributes { get; set; } + + public IDictionary Relationships { get; set; } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/ResourceObjects/ResourcePatchRequestObject.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/ResourceObjects/ResourcePatchRequestObject.cs new file mode 100644 index 0000000000..e642d93bbb --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/ResourceObjects/ResourcePatchRequestObject.cs @@ -0,0 +1,9 @@ +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.JsonApiObjects.ResourceObjects +{ + internal sealed class ResourcePatchRequestObject : ResourceObject + where TResource : IIdentifiable + { + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/ResourceObjects/ResourcePostRequestObject.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/ResourceObjects/ResourcePostRequestObject.cs new file mode 100644 index 0000000000..9447c74668 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/ResourceObjects/ResourcePostRequestObject.cs @@ -0,0 +1,9 @@ +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.JsonApiObjects.ResourceObjects +{ + internal sealed class ResourcePostRequestObject : ResourceObject + where TResource : IIdentifiable + { + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/ResourceObjects/ResourceResponseObject.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/ResourceObjects/ResourceResponseObject.cs new file mode 100644 index 0000000000..44fcfdcfe0 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/ResourceObjects/ResourceResponseObject.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.Links; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.JsonApiObjects.ResourceObjects +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + internal sealed class ResourceResponseObject : ResourceObject + where TResource : IIdentifiable + { + [Required] + public LinksInResourceObject Links { get; set; } + + public IDictionary Meta { get; set; } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/SingleData.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/SingleData.cs new file mode 100644 index 0000000000..616f357014 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/SingleData.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.ResourceObjects; + +namespace JsonApiDotNetCore.OpenApi.JsonApiObjects +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + internal abstract class SingleData + where TData : ResourceIdentifierObject + { + [Required] + public TData Data { get; set; } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiOperationIdSelector.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiOperationIdSelector.cs new file mode 100644 index 0000000000..36d6dc6d06 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiOperationIdSelector.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Humanizer; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.Documents; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.RelationshipData; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.Controllers; +using Newtonsoft.Json.Serialization; + +namespace JsonApiDotNetCore.OpenApi +{ + internal sealed class JsonApiOperationIdSelector + { + private const string ResourceOperationIdTemplate = "[Method] [PrimaryResourceName]"; + private const string ResourceCollectionOperationIdTemplate = ResourceOperationIdTemplate + " Collection"; + private const string SecondaryOperationIdTemplate = ResourceOperationIdTemplate + " [RelationshipName]"; + private const string RelationshipOperationIdTemplate = SecondaryOperationIdTemplate + " Relationship"; + + private static readonly IDictionary DocumentOpenTypeToOperationIdTemplateMap = new Dictionary + { + [typeof(ResourceCollectionResponseDocument<>)] = ResourceCollectionOperationIdTemplate, + [typeof(PrimaryResourceResponseDocument<>)] = ResourceOperationIdTemplate, + [typeof(ResourcePostRequestDocument<>)] = ResourceOperationIdTemplate, + [typeof(ResourcePatchRequestDocument<>)] = ResourceOperationIdTemplate, + [typeof(void)] = ResourceOperationIdTemplate, + [typeof(SecondaryResourceResponseDocument<>)] = SecondaryOperationIdTemplate, + [typeof(ResourceIdentifierCollectionResponseDocument<>)] = RelationshipOperationIdTemplate, + [typeof(ResourceIdentifierResponseDocument<>)] = RelationshipOperationIdTemplate, + [typeof(ToOneRelationshipRequestData<>)] = RelationshipOperationIdTemplate, + [typeof(ToManyRelationshipRequestData<>)] = RelationshipOperationIdTemplate + }; + + private readonly IControllerResourceMapping _controllerResourceMapping; + private readonly NamingStrategy _namingStrategy; + private readonly ResourceNameFormatter _formatter; + + public JsonApiOperationIdSelector(IControllerResourceMapping controllerResourceMapping, NamingStrategy namingStrategy) + { + ArgumentGuard.NotNull(controllerResourceMapping, nameof(controllerResourceMapping)); + ArgumentGuard.NotNull(namingStrategy, nameof(namingStrategy)); + + _controllerResourceMapping = controllerResourceMapping; + _namingStrategy = namingStrategy; + _formatter = new ResourceNameFormatter(namingStrategy); + } + + public string GetOperationId(ApiDescription endpoint) + { + ArgumentGuard.NotNull(endpoint, nameof(endpoint)); + + Type primaryResourceType = _controllerResourceMapping.GetResourceTypeForController(endpoint.ActionDescriptor.GetActionMethod().ReflectedType); + + string template = GetTemplate(primaryResourceType, endpoint); + + return ApplyTemplate(template, primaryResourceType, endpoint); + } + + private static string GetTemplate(Type primaryResourceType, ApiDescription endpoint) + { + Type requestDocumentType = GetDocumentType(primaryResourceType, endpoint); + + return DocumentOpenTypeToOperationIdTemplateMap[requestDocumentType]; + } + + private static Type GetDocumentType(Type primaryResourceType, ApiDescription endpoint) + { + ControllerParameterDescriptor requestBodyDescriptor = endpoint.ActionDescriptor.GetBodyParameterDescriptor(); + var producesResponseTypeAttribute = endpoint.ActionDescriptor.GetFilterMetadata(); + + Type documentType = requestBodyDescriptor?.ParameterType.GetGenericTypeDefinition() ?? + TryGetGenericTypeDefinition(producesResponseTypeAttribute.Type) ?? producesResponseTypeAttribute.Type; + + if (documentType == typeof(ResourceCollectionResponseDocument<>)) + { + Type documentResourceType = producesResponseTypeAttribute.Type.GetGenericArguments()[0]; + + if (documentResourceType != primaryResourceType) + { + documentType = typeof(SecondaryResourceResponseDocument<>); + } + } + + return documentType; + } + + private static Type TryGetGenericTypeDefinition(Type type) + { + return type.IsConstructedGenericType ? type.GetGenericTypeDefinition() : null; + } + + private string ApplyTemplate(string operationIdTemplate, Type primaryResourceType, ApiDescription endpoint) + { + string method = endpoint.HttpMethod!.ToLowerInvariant(); + string primaryResourceName = _formatter.FormatResourceName(primaryResourceType).Singularize(); + string relationshipName = operationIdTemplate.Contains("[RelationshipName]") ? endpoint.RelativePath.Split("/").Last() : string.Empty; + + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true + + string pascalCaseId = operationIdTemplate + .Replace("[Method]", method) + .Replace("[PrimaryResourceName]", primaryResourceName) + .Replace("[RelationshipName]", relationshipName); + + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore + + return _namingStrategy.GetPropertyName(pascalCaseId, false); + } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiPathParameter.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiPathParameter.cs new file mode 100644 index 0000000000..497ba9faa7 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiPathParameter.cs @@ -0,0 +1,9 @@ +#pragma warning disable AV1008 // Class should not be static + +namespace JsonApiDotNetCore.OpenApi +{ + internal static class JsonApiPathParameter + { + public const string RelationshipName = "relationshipName"; + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiRequestFormatMetadataProvider.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiRequestFormatMetadataProvider.cs new file mode 100644 index 0000000000..1fef9854f0 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiRequestFormatMetadataProvider.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.Documents; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.RelationshipData; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.Net.Http.Headers; + +namespace JsonApiDotNetCore.OpenApi +{ + internal sealed class JsonApiRequestFormatMetadataProvider : IInputFormatter, IApiRequestFormatMetadataProvider + { + private static readonly Type[] JsonApiRequestObjectOpenType = + { + typeof(ToManyRelationshipRequestData<>), + typeof(ToOneRelationshipRequestData<>), + typeof(ResourcePostRequestDocument<>), + typeof(ResourcePatchRequestDocument<>) + }; + + /// + public bool CanRead(InputFormatterContext context) + { + return false; + } + + /// + public Task ReadAsync(InputFormatterContext context) + { + throw new UnreachableCodeException(); + } + + /// + public IReadOnlyList GetSupportedContentTypes(string contentType, Type objectType) + { + ArgumentGuard.NotNullNorEmpty(contentType, nameof(contentType)); + ArgumentGuard.NotNull(objectType, nameof(objectType)); + + if (contentType == HeaderConstants.MediaType && objectType.IsGenericType && + JsonApiRequestObjectOpenType.Contains(objectType.GetGenericTypeDefinition())) + { + return new MediaTypeCollection + { + new MediaTypeHeaderValue(HeaderConstants.MediaType) + }; + } + + return new MediaTypeCollection(); + } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiRoutingTemplate.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiRoutingTemplate.cs new file mode 100644 index 0000000000..aff8ed85a8 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiRoutingTemplate.cs @@ -0,0 +1,13 @@ +#pragma warning disable AV1008 // Class should not be static + +namespace JsonApiDotNetCore.OpenApi +{ + internal static class JsonApiRoutingTemplate + { + public const string RelationshipNameUrlPlaceholder = "{" + JsonApiPathParameter.RelationshipName + "}"; + public const string RelationshipsPart = "relationships"; + public const string PrimaryEndpoint = "{id}"; + public const string SecondaryEndpoint = "{id}/" + RelationshipNameUrlPlaceholder; + public const string RelationshipEndpoint = "{id}/" + RelationshipsPart + "/" + RelationshipNameUrlPlaceholder; + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiSchemaIdSelector.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiSchemaIdSelector.cs new file mode 100644 index 0000000000..a9dac443b0 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiSchemaIdSelector.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Humanizer; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.Documents; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.RelationshipData; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.ResourceObjects; + +namespace JsonApiDotNetCore.OpenApi +{ + internal sealed class JsonApiSchemaIdSelector + { + private static readonly IDictionary OpenTypeToSchemaTemplateMap = new Dictionary + { + [typeof(ResourcePostRequestDocument<>)] = "###-post-request-document", + [typeof(ResourcePatchRequestDocument<>)] = "###-patch-request-document", + [typeof(ResourcePostRequestObject<>)] = "###-data-in-post-request", + [typeof(ResourcePatchRequestObject<>)] = "###-data-in-patch-request", + [typeof(ToOneRelationshipRequestData<>)] = "to-one-###-request-data", + [typeof(ToManyRelationshipRequestData<>)] = "to-many-###-request-data", + [typeof(PrimaryResourceResponseDocument<>)] = "###-primary-response-document", + [typeof(SecondaryResourceResponseDocument<>)] = "###-secondary-response-document", + [typeof(ResourceCollectionResponseDocument<>)] = "###-collection-response-document", + [typeof(ResourceIdentifierResponseDocument<>)] = "###-identifier-response-document", + [typeof(ResourceIdentifierCollectionResponseDocument<>)] = "###-identifier-collection-response-document", + [typeof(ToOneRelationshipResponseData<>)] = "to-one-###-response-data", + [typeof(ToManyRelationshipResponseData<>)] = "to-many-###-response-data", + [typeof(ResourceResponseObject<>)] = "###-data-in-response", + [typeof(ResourceIdentifierObject<>)] = "###-identifier" + }; + + private readonly ResourceNameFormatter _formatter; + private readonly IResourceContextProvider _resourceContextProvider; + + public JsonApiSchemaIdSelector(ResourceNameFormatter formatter, IResourceContextProvider resourceContextProvider) + { + ArgumentGuard.NotNull(formatter, nameof(formatter)); + ArgumentGuard.NotNull(resourceContextProvider, nameof(resourceContextProvider)); + + _formatter = formatter; + _resourceContextProvider = resourceContextProvider; + } + + public string GetSchemaId(Type type) + { + ArgumentGuard.NotNull(type, nameof(type)); + + ResourceContext resourceContext = _resourceContextProvider.GetResourceContext(type); + + if (resourceContext != null) + { + return resourceContext.PublicName.Singularize(); + } + + if (type.IsConstructedGenericType && OpenTypeToSchemaTemplateMap.ContainsKey(type.GetGenericTypeDefinition())) + { + Type resourceType = type.GetGenericArguments().First(); + string resourceName = _formatter.FormatResourceName(resourceType).Singularize(); + + string template = OpenTypeToSchemaTemplateMap[type.GetGenericTypeDefinition()]; + return template.Replace("###", resourceName); + } + + // Used for a fixed set of types, such as jsonapi-object, links-in-many-resource-document etc. + return _formatter.FormatResourceName(type).Singularize(); + } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/ObjectExtensions.cs b/src/JsonApiDotNetCore.OpenApi/ObjectExtensions.cs new file mode 100644 index 0000000000..573067520b --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/ObjectExtensions.cs @@ -0,0 +1,19 @@ +using System; +using System.Reflection; +using System.Threading; + +namespace JsonApiDotNetCore.OpenApi +{ + internal static class ObjectExtensions + { + private static readonly Lazy MemberwiseCloneMethod = new(() => + typeof(object).GetMethod(nameof(MemberwiseClone), BindingFlags.Instance | BindingFlags.NonPublic), LazyThreadSafetyMode.ExecutionAndPublication); + + public static object MemberwiseClone(this object source) + { + ArgumentGuard.NotNull(source, nameof(source)); + + return MemberwiseCloneMethod.Value.Invoke(source, null); + } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/OpenApiEndpointConvention.cs b/src/JsonApiDotNetCore.OpenApi/OpenApiEndpointConvention.cs index a9b58bdd16..89829d5d25 100644 --- a/src/JsonApiDotNetCore.OpenApi/OpenApiEndpointConvention.cs +++ b/src/JsonApiDotNetCore.OpenApi/OpenApiEndpointConvention.cs @@ -1,19 +1,170 @@ +using System; +using System.Collections.Generic; using System.Linq; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.OpenApi.JsonApiMetadata; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ApplicationModels; -using Microsoft.AspNetCore.Mvc.Routing; namespace JsonApiDotNetCore.OpenApi { + /// + /// Sets metadata on controllers for OpenAPI documentation generation by Swagger. Only targets JsonApiDotNetCore controllers. + /// internal sealed class OpenApiEndpointConvention : IActionModelConvention { + private readonly IResourceContextProvider _resourceContextProvider; + private readonly IControllerResourceMapping _controllerResourceMapping; + private readonly EndpointResolver _endpointResolver = new(); + + public OpenApiEndpointConvention(IResourceContextProvider resourceContextProvider, IControllerResourceMapping controllerResourceMapping) + { + ArgumentGuard.NotNull(resourceContextProvider, nameof(resourceContextProvider)); + ArgumentGuard.NotNull(controllerResourceMapping, nameof(controllerResourceMapping)); + + _resourceContextProvider = resourceContextProvider; + _controllerResourceMapping = controllerResourceMapping; + } + public void Apply(ActionModel action) { ArgumentGuard.NotNull(action, nameof(action)); - if (!action.ActionMethod.GetCustomAttributes(true).OfType().Any()) + JsonApiEndpoint? endpoint = _endpointResolver.Get(action.ActionMethod); + + if (endpoint == null || ShouldSuppressEndpoint(endpoint.Value, action.Controller.ControllerType)) { action.ApiExplorer.IsVisible = false; + + return; } + + SetResponseMetadata(action, endpoint.Value); + + SetRequestMetadata(action, endpoint.Value); + } + + private bool ShouldSuppressEndpoint(JsonApiEndpoint endpoint, Type controllerType) + { + if (IsSecondaryOrRelationshipEndpoint(endpoint)) + { + IReadOnlyCollection relationships = GetRelationshipsOfPrimaryResource(controllerType); + + if (!relationships.Any()) + { + return true; + } + + if (endpoint == JsonApiEndpoint.DeleteRelationship || endpoint == JsonApiEndpoint.PostRelationship) + { + return !relationships.OfType().Any(); + } + } + + return false; + } + + private IReadOnlyCollection GetRelationshipsOfPrimaryResource(Type controllerType) + { + Type primaryResourceOfEndpointType = _controllerResourceMapping.GetResourceTypeForController(controllerType); + + ResourceContext primaryResourceContext = _resourceContextProvider.GetResourceContext(primaryResourceOfEndpointType); + + return primaryResourceContext.Relationships; + } + + private static bool IsSecondaryOrRelationshipEndpoint(JsonApiEndpoint endpoint) + { + return endpoint == JsonApiEndpoint.GetSecondary || endpoint == JsonApiEndpoint.GetRelationship || endpoint == JsonApiEndpoint.PostRelationship || + endpoint == JsonApiEndpoint.PatchRelationship || endpoint == JsonApiEndpoint.DeleteRelationship; + } + + private void SetResponseMetadata(ActionModel action, JsonApiEndpoint endpoint) + { + IList statusCodes = GetStatusCodesForEndpoint(endpoint); + + foreach (int statusCode in statusCodes) + { + action.Filters.Add(new ProducesResponseTypeAttribute(statusCode)); + + switch (endpoint) + { + case JsonApiEndpoint.GetCollection when statusCode == StatusCodes.Status200OK: + case JsonApiEndpoint.Post when statusCode == StatusCodes.Status201Created: + case JsonApiEndpoint.Patch when statusCode == StatusCodes.Status200OK: + case JsonApiEndpoint.GetSingle when statusCode == StatusCodes.Status200OK: + case JsonApiEndpoint.GetSecondary when statusCode == StatusCodes.Status200OK: + case JsonApiEndpoint.GetRelationship when statusCode == StatusCodes.Status200OK: + { + action.Filters.Add(new ProducesAttribute(HeaderConstants.MediaType)); + break; + } + } + } + } + + private static IList GetStatusCodesForEndpoint(JsonApiEndpoint endpoint) + { + switch (endpoint) + { + case JsonApiEndpoint.GetCollection: + case JsonApiEndpoint.GetSingle: + case JsonApiEndpoint.GetSecondary: + case JsonApiEndpoint.GetRelationship: + { + return new[] + { + StatusCodes.Status200OK + }; + } + case JsonApiEndpoint.Post: + { + return new[] + { + StatusCodes.Status201Created, + StatusCodes.Status204NoContent + }; + } + case JsonApiEndpoint.Patch: + { + return new[] + { + StatusCodes.Status200OK, + StatusCodes.Status204NoContent + }; + } + case JsonApiEndpoint.Delete: + case JsonApiEndpoint.PostRelationship: + case JsonApiEndpoint.PatchRelationship: + case JsonApiEndpoint.DeleteRelationship: + { + return new[] + { + StatusCodes.Status204NoContent + }; + } + default: + { + throw new UnreachableCodeException(); + } + } + } + + private static void SetRequestMetadata(ActionModel action, JsonApiEndpoint endpoint) + { + if (RequiresRequestBody(endpoint)) + { + action.Filters.Add(new ConsumesAttribute(HeaderConstants.MediaType)); + } + } + + private static bool RequiresRequestBody(JsonApiEndpoint endpoint) + { + return endpoint is JsonApiEndpoint.Post || endpoint is JsonApiEndpoint.Patch || endpoint is JsonApiEndpoint.PostRelationship || + endpoint is JsonApiEndpoint.PatchRelationship || endpoint is JsonApiEndpoint.DeleteRelationship; } } } diff --git a/src/JsonApiDotNetCore.OpenApi/ParameterInfoExtensions.cs b/src/JsonApiDotNetCore.OpenApi/ParameterInfoExtensions.cs new file mode 100644 index 0000000000..9d46706586 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/ParameterInfoExtensions.cs @@ -0,0 +1,37 @@ +using System; +using System.Reflection; +using System.Threading; + +namespace JsonApiDotNetCore.OpenApi +{ + internal static class ParameterInfoExtensions + { + private static readonly Lazy NameField = new(() => + typeof(ParameterInfo).GetField("NameImpl", BindingFlags.Instance | BindingFlags.NonPublic), LazyThreadSafetyMode.ExecutionAndPublication); + + private static readonly Lazy ParameterTypeField = new(() => + typeof(ParameterInfo).GetField("ClassImpl", BindingFlags.Instance | BindingFlags.NonPublic), LazyThreadSafetyMode.ExecutionAndPublication); + + public static ParameterInfo WithName(this ParameterInfo source, string name) + { + ArgumentGuard.NotNull(source, nameof(source)); + ArgumentGuard.NotNullNorEmpty(name, nameof(name)); + + var cloned = (ParameterInfo)source.MemberwiseClone(); + NameField.Value.SetValue(cloned, name); + + return cloned; + } + + public static ParameterInfo WithParameterType(this ParameterInfo source, Type parameterType) + { + ArgumentGuard.NotNull(source, nameof(source)); + ArgumentGuard.NotNull(parameterType, nameof(parameterType)); + + var cloned = (ParameterInfo)source.MemberwiseClone(); + ParameterTypeField.Value.SetValue(cloned, parameterType); + + return cloned; + } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/ServiceCollectionExtensions.cs b/src/JsonApiDotNetCore.OpenApi/ServiceCollectionExtensions.cs index e94d7458cf..f86288113a 100644 --- a/src/JsonApiDotNetCore.OpenApi/ServiceCollectionExtensions.cs +++ b/src/JsonApiDotNetCore.OpenApi/ServiceCollectionExtensions.cs @@ -1,5 +1,14 @@ using System; +using System.Collections.Generic; +using System.Reflection; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.OpenApi.SwaggerComponents; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.Extensions.DependencyInjection; +using Newtonsoft.Json.Serialization; +using Swashbuckle.AspNetCore.Swagger; using Swashbuckle.AspNetCore.SwaggerGen; namespace JsonApiDotNetCore.OpenApi @@ -14,11 +23,114 @@ public static void AddOpenApi(this IServiceCollection services, IMvcCoreBuilder ArgumentGuard.NotNull(services, nameof(services)); ArgumentGuard.NotNull(mvcBuilder, nameof(mvcBuilder)); + AddCustomApiExplorer(services, mvcBuilder); + + AddCustomSwaggerComponents(services); + + using ServiceProvider provider = services.BuildServiceProvider(); + using IServiceScope scope = provider.CreateScope(); + AddSwaggerGenerator(scope, services, setupSwaggerGenAction); + AddSwashbuckleCliCompatibility(scope, mvcBuilder); + AddOpenApiEndpointConvention(scope, mvcBuilder); + } + + private static void AddCustomApiExplorer(IServiceCollection services, IMvcCoreBuilder mvcBuilder) + { + services.AddSingleton(provider => + { + var resourceGraph = provider.GetRequiredService(); + var controllerResourceMapping = provider.GetRequiredService(); + var actionDescriptorCollectionProvider = provider.GetRequiredService(); + var apiDescriptionProviders = provider.GetRequiredService>(); + + JsonApiActionDescriptorCollectionProvider descriptorCollectionProviderWrapper = + new(resourceGraph, controllerResourceMapping, actionDescriptorCollectionProvider); + + return new ApiDescriptionGroupCollectionProvider(descriptorCollectionProviderWrapper, apiDescriptionProviders); + }); + mvcBuilder.AddApiExplorer(); - mvcBuilder.AddMvcOptions(options => options.Conventions.Add(new OpenApiEndpointConvention())); + mvcBuilder.AddMvcOptions(options => options.InputFormatters.Add(new JsonApiRequestFormatMetadataProvider())); + } + + private static void AddSwaggerGenerator(IServiceScope scope, IServiceCollection services, Action setupSwaggerGenAction) + { + var controllerResourceMapping = scope.ServiceProvider.GetRequiredService(); + var resourceContextProvider = scope.ServiceProvider.GetRequiredService(); + var jsonApiOptions = scope.ServiceProvider.GetRequiredService(); + NamingStrategy namingStrategy = ((DefaultContractResolver)jsonApiOptions.SerializerSettings.ContractResolver)!.NamingStrategy; + + AddSchemaGenerator(services); + + services.AddSwaggerGen(swaggerGenOptions => + { + SetOperationInfo(swaggerGenOptions, controllerResourceMapping, resourceContextProvider, namingStrategy); + SetSchemaIdSelector(swaggerGenOptions, resourceContextProvider, namingStrategy); + swaggerGenOptions.DocumentFilter(); + + setupSwaggerGenAction?.Invoke(swaggerGenOptions); + }); + } + + private static void AddSchemaGenerator(IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + } + + private static void SetOperationInfo(SwaggerGenOptions swaggerGenOptions, IControllerResourceMapping controllerResourceMapping, + IResourceContextProvider resourceContextProvider, NamingStrategy namingStrategy) + { + swaggerGenOptions.TagActionsBy(description => GetOperationTags(description, controllerResourceMapping, resourceContextProvider)); + + JsonApiOperationIdSelector jsonApiOperationIdSelector = new(controllerResourceMapping, namingStrategy); + swaggerGenOptions.CustomOperationIds(jsonApiOperationIdSelector.GetOperationId); + } + + private static IList GetOperationTags(ApiDescription description, IControllerResourceMapping controllerResourceMapping, + IResourceContextProvider resourceContextProvider) + { + MethodInfo actionMethod = description.ActionDescriptor.GetActionMethod(); + Type resourceType = controllerResourceMapping.GetResourceTypeForController(actionMethod.ReflectedType); + ResourceContext resourceContext = resourceContextProvider.GetResourceContext(resourceType); + + return new[] + { + resourceContext.PublicName + }; + } + + private static void SetSchemaIdSelector(SwaggerGenOptions swaggerGenOptions, IResourceContextProvider resourceContextProvider, + NamingStrategy namingStrategy) + { + ResourceNameFormatter resourceNameFormatter = new(namingStrategy); + JsonApiSchemaIdSelector jsonApiObjectSchemaSelector = new(resourceNameFormatter, resourceContextProvider); + + swaggerGenOptions.CustomSchemaIds(type => jsonApiObjectSchemaSelector.GetSchemaId(type)); + } + + private static void AddCustomSwaggerComponents(IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + } + + private static void AddSwashbuckleCliCompatibility(IServiceScope scope, IMvcCoreBuilder mvcBuilder) + { + // See https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/1957 for why this is needed. + var routingConvention = scope.ServiceProvider.GetRequiredService(); + mvcBuilder.AddMvcOptions(options => options.Conventions.Insert(0, routingConvention)); + } + + private static void AddOpenApiEndpointConvention(IServiceScope scope, IMvcCoreBuilder mvcBuilder) + { + var resourceContextProvider = scope.ServiceProvider.GetRequiredService(); + var controllerResourceMapping = scope.ServiceProvider.GetRequiredService(); - services.AddSwaggerGen(setupSwaggerGenAction); + mvcBuilder.AddMvcOptions(options => options.Conventions.Add(new OpenApiEndpointConvention(resourceContextProvider, controllerResourceMapping))); } } } diff --git a/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/CachingSwaggerGenerator.cs b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/CachingSwaggerGenerator.cs new file mode 100644 index 0000000000..0a5d1180b3 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/CachingSwaggerGenerator.cs @@ -0,0 +1,32 @@ +using System.Collections.Concurrent; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.Swagger; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace JsonApiDotNetCore.OpenApi.SwaggerComponents +{ + /// + /// The default implementation re-renders the OpenApiDocument every time it is requested, which is redundant in our case. + /// This implementation provides a very basic caching layer. + /// + internal sealed class CachingSwaggerGenerator : ISwaggerProvider + { + private readonly SwaggerGenerator _defaultSwaggerGenerator; + private readonly ConcurrentDictionary _openApiDocumentCache = new(); + + public CachingSwaggerGenerator(SwaggerGenerator defaultSwaggerGenerator) + { + ArgumentGuard.NotNull(defaultSwaggerGenerator, nameof(defaultSwaggerGenerator)); + _defaultSwaggerGenerator = defaultSwaggerGenerator; + } + + public OpenApiDocument GetSwagger(string documentName, string host = null, string basePath = null) + { + ArgumentGuard.NotNullNorEmpty(documentName, nameof(documentName)); + + string cacheKey = $"{documentName}#{host}#{basePath}"; + + return _openApiDocumentCache.GetOrAdd(cacheKey, _ => _defaultSwaggerGenerator.GetSwagger(documentName, host, basePath)); + } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/EndpointOrderingFilter.cs b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/EndpointOrderingFilter.cs new file mode 100644 index 0000000000..2d9a1ba39f --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/EndpointOrderingFilter.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using JetBrains.Annotations; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace JsonApiDotNetCore.OpenApi.SwaggerComponents +{ + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] + internal sealed class EndpointOrderingFilter : IDocumentFilter + { + private static readonly Regex RelationshipNameInUrlPattern = + new($".*{JsonApiRoutingTemplate.PrimaryEndpoint}/(?>{JsonApiRoutingTemplate.RelationshipsPart}\\/)?(\\w+)", RegexOptions.Compiled); + + public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context) + { + ArgumentGuard.NotNull(swaggerDoc, nameof(swaggerDoc)); + ArgumentGuard.NotNull(context, nameof(context)); + + List> orderedEndpoints = swaggerDoc.Paths.OrderBy(GetPrimaryResourcePublicName) + .ThenBy(GetRelationshipName).ThenBy(IsSecondaryEndpoint).ToList(); + + swaggerDoc.Paths.Clear(); + + foreach ((string url, OpenApiPathItem path) in orderedEndpoints) + { + swaggerDoc.Paths.Add(url, path); + } + } + + private static string GetPrimaryResourcePublicName(KeyValuePair entry) + { + return entry.Value.Operations.First().Value.Tags.First().Name; + } + + private static bool IsSecondaryEndpoint(KeyValuePair entry) + { + return entry.Key.Contains("/" + JsonApiRoutingTemplate.RelationshipsPart); + } + + private static string GetRelationshipName(KeyValuePair entry) + { + Match match = RelationshipNameInUrlPattern.Match(entry.Key); + + return match.Success ? match.Groups[1].Value : string.Empty; + } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ISchemaRepositoryAccessor.cs b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ISchemaRepositoryAccessor.cs new file mode 100644 index 0000000000..a3e40d79c4 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ISchemaRepositoryAccessor.cs @@ -0,0 +1,9 @@ +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace JsonApiDotNetCore.OpenApi.SwaggerComponents +{ + internal interface ISchemaRepositoryAccessor + { + SchemaRepository Current { get; } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/JsonApiDataContractResolver.cs b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/JsonApiDataContractResolver.cs new file mode 100644 index 0000000000..199c33d2ff --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/JsonApiDataContractResolver.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using Newtonsoft.Json; +using Swashbuckle.AspNetCore.Newtonsoft; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace JsonApiDotNetCore.OpenApi.SwaggerComponents +{ + /// + /// For schema generation, we rely on from Swashbuckle for all but our own JSON:API types. + /// + internal sealed class JsonApiDataContractResolver : ISerializerDataContractResolver + { + private readonly NewtonsoftDataContractResolver _dataContractResolver; + private readonly IResourceContextProvider _resourceContextProvider; + + public JsonApiDataContractResolver(IResourceContextProvider resourceContextProvider, IJsonApiOptions jsonApiOptions) + { + ArgumentGuard.NotNull(resourceContextProvider, nameof(resourceContextProvider)); + ArgumentGuard.NotNull(jsonApiOptions, nameof(jsonApiOptions)); + + _resourceContextProvider = resourceContextProvider; + + JsonSerializerSettings serializerSettings = jsonApiOptions.SerializerSettings ?? new JsonSerializerSettings(); + _dataContractResolver = new NewtonsoftDataContractResolver(serializerSettings); + } + + public DataContract GetDataContractForType(Type type) + { + ArgumentGuard.NotNull(type, nameof(type)); + + if (type == typeof(IIdentifiable)) + { + // We have no way of telling Swashbuckle to opt out on this type, the closest we can get is return a contract with type Unknown. + return DataContract.ForDynamic(typeof(object)); + } + + DataContract dataContract = _dataContractResolver.GetDataContractForType(type); + + IList replacementProperties = null; + + if (type.IsAssignableTo(typeof(IIdentifiable))) + { + replacementProperties = GetDataPropertiesThatExistInResourceContext(type, dataContract); + } + + if (replacementProperties != null) + { + dataContract = ReplacePropertiesInDataContract(dataContract, replacementProperties); + } + + return dataContract; + } + + private static DataContract ReplacePropertiesInDataContract(DataContract dataContract, IEnumerable dataProperties) + { + return DataContract.ForObject(dataContract.UnderlyingType, dataProperties, dataContract.ObjectExtensionDataType, + dataContract.ObjectTypeNameProperty, dataContract.ObjectTypeNameValue); + } + + private IList GetDataPropertiesThatExistInResourceContext(Type resourceType, DataContract dataContract) + { + ResourceContext resourceContext = _resourceContextProvider.GetResourceContext(resourceType); + var dataProperties = new List(); + + foreach (DataProperty property in dataContract.ObjectProperties) + { + if (property.MemberInfo.Name == nameof(Identifiable.Id)) + { + // Schemas of JsonApiDotNetCore resources will obtain an Id property through inheritance of a resource identifier type. + continue; + } + + ResourceFieldAttribute matchingField = resourceContext.Fields.SingleOrDefault(field => + IsPropertyCompatibleWithMember(field.Property, property.MemberInfo)); + + if (matchingField != null) + { + DataProperty matchingProperty = matchingField.PublicName != property.Name + ? ChangeDataPropertyName(property, matchingField.PublicName) + : property; + + dataProperties.Add(matchingProperty); + } + } + + return dataProperties; + } + + private static DataProperty ChangeDataPropertyName(DataProperty property, string name) + { + return new(name, property.MemberType, property.IsRequired, property.IsNullable, property.IsReadOnly, property.IsWriteOnly, property.MemberInfo); + } + + private static bool IsPropertyCompatibleWithMember(PropertyInfo property, MemberInfo member) + { + // In JsonApiDotNetCore the PropertyInfo for Id stored in AttrAttribute is that of the ReflectedType, whereas Newtonsoft uses the DeclaringType. + return property == member || property.DeclaringType?.GetProperty(property.Name) == member; + } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/JsonApiObjectNullabilityProcessor.cs b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/JsonApiObjectNullabilityProcessor.cs new file mode 100644 index 0000000000..29d1d71bd6 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/JsonApiObjectNullabilityProcessor.cs @@ -0,0 +1,131 @@ +using Microsoft.OpenApi.Models; + +namespace JsonApiDotNetCore.OpenApi.SwaggerComponents +{ + /// + /// Removes unwanted nullability of entries in schemas of JSON:API documents. + /// + /// + /// Initially these entries are marked nullable by Swashbuckle because nullable reference types are not enabled. This post-processing step can be removed + /// entirely once we enable nullable reference types. + /// + internal sealed class JsonApiObjectNullabilityProcessor + { + private readonly ISchemaRepositoryAccessor _schemaRepositoryAccessor; + + public JsonApiObjectNullabilityProcessor(ISchemaRepositoryAccessor schemaRepositoryAccessor) + { + ArgumentGuard.NotNull(schemaRepositoryAccessor, nameof(schemaRepositoryAccessor)); + + _schemaRepositoryAccessor = schemaRepositoryAccessor; + } + + public void ClearDocumentProperties(OpenApiSchema referenceSchemaForDocument) + { + ArgumentGuard.NotNull(referenceSchemaForDocument, nameof(referenceSchemaForDocument)); + + OpenApiSchema fullSchemaForDocument = _schemaRepositoryAccessor.Current.Schemas[referenceSchemaForDocument.Reference.Id]; + + ClearMetaObjectNullability(fullSchemaForDocument); + ClearJsonapiObjectNullability(fullSchemaForDocument); + ClearLinksObjectNullability(fullSchemaForDocument); + + OpenApiSchema fullSchemaForResourceObject = TryGetFullSchemaForResourceObject(fullSchemaForDocument); + + if (fullSchemaForResourceObject != null) + { + ClearResourceObjectNullability(fullSchemaForResourceObject); + } + } + + private static void ClearMetaObjectNullability(OpenApiSchema fullSchema) + { + if (fullSchema.Properties.ContainsKey(JsonApiObjectPropertyName.MetaObject)) + { + fullSchema.Properties[JsonApiObjectPropertyName.MetaObject].Nullable = false; + } + } + + private void ClearJsonapiObjectNullability(OpenApiSchema fullSchema) + { + if (fullSchema.Properties.ContainsKey(JsonApiObjectPropertyName.JsonapiObject)) + { + OpenApiSchema fullSchemaForJsonapiObject = + _schemaRepositoryAccessor.Current.Schemas[fullSchema.Properties[JsonApiObjectPropertyName.JsonapiObject].Reference.Id]; + + fullSchemaForJsonapiObject.Properties[JsonApiObjectPropertyName.JsonapiObjectVersion].Nullable = false; + fullSchemaForJsonapiObject.Properties[JsonApiObjectPropertyName.JsonapiObjectExt].Nullable = false; + fullSchemaForJsonapiObject.Properties[JsonApiObjectPropertyName.JsonapiObjectProfile].Nullable = false; + ClearMetaObjectNullability(fullSchemaForJsonapiObject); + } + } + + private void ClearLinksObjectNullability(OpenApiSchema fullSchema) + { + if (fullSchema.Properties.ContainsKey(JsonApiObjectPropertyName.LinksObject)) + { + OpenApiSchema fullSchemaForLinksObject = + _schemaRepositoryAccessor.Current.Schemas[fullSchema.Properties[JsonApiObjectPropertyName.LinksObject].Reference.Id]; + + foreach (OpenApiSchema schemaForEntryInLinksObject in fullSchemaForLinksObject.Properties.Values) + { + schemaForEntryInLinksObject.Nullable = false; + } + } + } + + private OpenApiSchema TryGetFullSchemaForResourceObject(OpenApiSchema fullSchemaForDocument) + { + OpenApiSchema schemaForDataObject = fullSchemaForDocument.Properties[JsonApiObjectPropertyName.Data]; + OpenApiReference dataSchemaReference = schemaForDataObject.Type == "array" ? schemaForDataObject.Items.Reference : schemaForDataObject.Reference; + + if (dataSchemaReference == null) + { + return null; + } + + return _schemaRepositoryAccessor.Current.Schemas[dataSchemaReference.Id]; + } + + private void ClearResourceObjectNullability(OpenApiSchema fullSchemaForValueOfData) + { + ClearMetaObjectNullability(fullSchemaForValueOfData); + ClearLinksObjectNullability(fullSchemaForValueOfData); + ClearAttributesObjectNullability(fullSchemaForValueOfData); + ClearRelationshipsObjectNullability(fullSchemaForValueOfData); + } + + private void ClearAttributesObjectNullability(OpenApiSchema fullSchemaForResourceObject) + { + if (fullSchemaForResourceObject.Properties.ContainsKey(JsonApiObjectPropertyName.AttributesObject)) + { + OpenApiSchema fullSchemaForAttributesObject = _schemaRepositoryAccessor.Current.Schemas[ + fullSchemaForResourceObject.Properties[JsonApiObjectPropertyName.AttributesObject].Reference.Id]; + + fullSchemaForAttributesObject.Nullable = false; + } + } + + private void ClearRelationshipsObjectNullability(OpenApiSchema fullSchemaForResourceObject) + { + if (fullSchemaForResourceObject.Properties.ContainsKey(JsonApiObjectPropertyName.RelationshipsObject)) + { + OpenApiSchema fullSchemaForRelationshipsObject = _schemaRepositoryAccessor.Current.Schemas[ + fullSchemaForResourceObject.Properties[JsonApiObjectPropertyName.RelationshipsObject].Reference.Id]; + + fullSchemaForRelationshipsObject.Nullable = false; + ClearRelationshipsDataNullability(fullSchemaForRelationshipsObject); + } + } + + private void ClearRelationshipsDataNullability(OpenApiSchema fullSchemaForRelationshipsObject) + { + foreach (OpenApiSchema relationshipObjectData in fullSchemaForRelationshipsObject.Properties.Values) + { + OpenApiSchema fullSchemaForRelationshipsObjectData = _schemaRepositoryAccessor.Current.Schemas[relationshipObjectData.Reference.Id]; + ClearLinksObjectNullability(fullSchemaForRelationshipsObjectData); + ClearMetaObjectNullability(fullSchemaForRelationshipsObjectData); + } + } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/JsonApiSchemaGenerator.cs b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/JsonApiSchemaGenerator.cs new file mode 100644 index 0000000000..b1589e2004 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/JsonApiSchemaGenerator.cs @@ -0,0 +1,152 @@ +using System; +using System.Linq; +using System.Reflection; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.OpenApi.JsonApiObjects; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.Documents; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.RelationshipData; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace JsonApiDotNetCore.OpenApi.SwaggerComponents +{ + internal sealed class JsonApiSchemaGenerator : ISchemaGenerator + { + private static readonly Type[] JsonApiResourceDocumentOpenTypes = + { + typeof(ResourceCollectionResponseDocument<>), + typeof(PrimaryResourceResponseDocument<>), + typeof(SecondaryResourceResponseDocument<>), + typeof(ResourcePostRequestDocument<>), + typeof(ResourcePatchRequestDocument<>) + }; + + private static readonly Type[] SingleNonPrimaryDataDocumentOpenTypes = + { + typeof(ToOneRelationshipRequestData<>), + typeof(ResourceIdentifierResponseDocument<>), + typeof(SecondaryResourceResponseDocument<>) + }; + + private static readonly Type[] JsonApiResourceIdentifierDocumentOpenTypes = + { + typeof(ResourceIdentifierCollectionResponseDocument<>), + typeof(ResourceIdentifierResponseDocument<>) + }; + + private readonly ISchemaGenerator _defaultSchemaGenerator; + private readonly ResourceObjectSchemaGenerator _resourceObjectSchemaGenerator; + private readonly NullableReferenceSchemaGenerator _nullableReferenceSchemaGenerator; + private readonly JsonApiObjectNullabilityProcessor _jsonApiObjectNullabilityProcessor; + private readonly SchemaRepositoryAccessor _schemaRepositoryAccessor = new(); + + public JsonApiSchemaGenerator(SchemaGenerator defaultSchemaGenerator, IResourceContextProvider resourceContextProvider, IJsonApiOptions jsonApiOptions) + { + ArgumentGuard.NotNull(defaultSchemaGenerator, nameof(defaultSchemaGenerator)); + ArgumentGuard.NotNull(resourceContextProvider, nameof(resourceContextProvider)); + ArgumentGuard.NotNull(jsonApiOptions, nameof(jsonApiOptions)); + + _defaultSchemaGenerator = defaultSchemaGenerator; + _nullableReferenceSchemaGenerator = new NullableReferenceSchemaGenerator(_schemaRepositoryAccessor); + _jsonApiObjectNullabilityProcessor = new JsonApiObjectNullabilityProcessor(_schemaRepositoryAccessor); + + _resourceObjectSchemaGenerator = + new ResourceObjectSchemaGenerator(defaultSchemaGenerator, resourceContextProvider, jsonApiOptions, _schemaRepositoryAccessor); + } + + public OpenApiSchema GenerateSchema(Type type, SchemaRepository schemaRepository, MemberInfo memberInfo = null, ParameterInfo parameterInfo = null) + { + ArgumentGuard.NotNull(type, nameof(type)); + ArgumentGuard.NotNull(schemaRepository, nameof(schemaRepository)); + + _schemaRepositoryAccessor.Current = schemaRepository; + + if (schemaRepository.TryLookupByType(type, out OpenApiSchema jsonApiDocumentSchema)) + { + return jsonApiDocumentSchema; + } + + OpenApiSchema schema = IsJsonApiResourceDocument(type) + ? GenerateResourceJsonApiDocumentSchema(type) + : _defaultSchemaGenerator.GenerateSchema(type, schemaRepository, memberInfo, parameterInfo); + + if (IsSingleNonPrimaryDataDocument(type)) + { + SetDataObjectSchemaToNullable(schema); + } + + if (IsJsonApiDocument(type)) + { + RemoveNotApplicableNullability(schema); + } + + return schema; + } + + private static bool IsJsonApiResourceDocument(Type type) + { + return type.IsConstructedGenericType && JsonApiResourceDocumentOpenTypes.Contains(type.GetGenericTypeDefinition()); + } + + private static bool IsJsonApiDocument(Type type) + { + return IsJsonApiResourceDocument(type) || IsJsonApiResourceIdentifierDocument(type); + } + + private static bool IsJsonApiResourceIdentifierDocument(Type type) + { + return type.IsConstructedGenericType && JsonApiResourceIdentifierDocumentOpenTypes.Contains(type.GetGenericTypeDefinition()); + } + + private OpenApiSchema GenerateResourceJsonApiDocumentSchema(Type type) + { + Type resourceObjectType = type.BaseType!.GenericTypeArguments[0]; + + if (!_schemaRepositoryAccessor.Current.TryLookupByType(resourceObjectType, out OpenApiSchema referenceSchemaForResourceObject)) + { + referenceSchemaForResourceObject = _resourceObjectSchemaGenerator.GenerateSchema(resourceObjectType); + } + + OpenApiSchema referenceSchemaForDocument = _defaultSchemaGenerator.GenerateSchema(type, _schemaRepositoryAccessor.Current); + OpenApiSchema fullSchemaForDocument = _schemaRepositoryAccessor.Current.Schemas[referenceSchemaForDocument.Reference.Id]; + + OpenApiSchema referenceSchemaForDataObject = + IsSingleDataDocument(type) ? referenceSchemaForResourceObject : CreateArrayTypeDataSchema(referenceSchemaForResourceObject); + + fullSchemaForDocument.Properties[JsonApiObjectPropertyName.Data] = referenceSchemaForDataObject; + + return referenceSchemaForDocument; + } + + private static bool IsSingleDataDocument(Type type) + { + return type.BaseType?.IsConstructedGenericType == true && type.BaseType.GetGenericTypeDefinition() == typeof(SingleData<>); + } + + private static bool IsSingleNonPrimaryDataDocument(Type type) + { + return type.IsConstructedGenericType && SingleNonPrimaryDataDocumentOpenTypes.Contains(type.GetGenericTypeDefinition()); + } + + private void SetDataObjectSchemaToNullable(OpenApiSchema referenceSchemaForDocument) + { + OpenApiSchema fullSchemaForDocument = _schemaRepositoryAccessor.Current.Schemas[referenceSchemaForDocument.Reference.Id]; + OpenApiSchema referenceSchemaForData = fullSchemaForDocument.Properties[JsonApiObjectPropertyName.Data]; + fullSchemaForDocument.Properties[JsonApiObjectPropertyName.Data] = _nullableReferenceSchemaGenerator.GenerateSchema(referenceSchemaForData); + } + + private static OpenApiSchema CreateArrayTypeDataSchema(OpenApiSchema referenceSchemaForResourceObject) + { + return new() + { + Items = referenceSchemaForResourceObject, + Type = "array" + }; + } + + private void RemoveNotApplicableNullability(OpenApiSchema schema) + { + _jsonApiObjectNullabilityProcessor.ClearDocumentProperties(schema); + } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/NullableReferenceSchemaGenerator.cs b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/NullableReferenceSchemaGenerator.cs new file mode 100644 index 0000000000..3449b2abfc --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/NullableReferenceSchemaGenerator.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using Microsoft.OpenApi.Models; + +namespace JsonApiDotNetCore.OpenApi.SwaggerComponents +{ + internal sealed class NullableReferenceSchemaGenerator + { + private static readonly NullableReferenceSchemaStrategy NullableReferenceStrategy = + Enum.Parse(NullableReferenceSchemaStrategy.Implicit.ToString()); + + private static OpenApiSchema _referenceSchemaForNullValue; + private readonly ISchemaRepositoryAccessor _schemaRepositoryAccessor; + + public NullableReferenceSchemaGenerator(ISchemaRepositoryAccessor schemaRepositoryAccessor) + { + ArgumentGuard.NotNull(schemaRepositoryAccessor, nameof(schemaRepositoryAccessor)); + + _schemaRepositoryAccessor = schemaRepositoryAccessor; + } + + public OpenApiSchema GenerateSchema(OpenApiSchema referenceSchema) + { + ArgumentGuard.NotNull(referenceSchema, nameof(referenceSchema)); + + return new OpenApiSchema + { + OneOf = new List + { + referenceSchema, + GetNullableReferenceSchema() + } + }; + } + + private OpenApiSchema GetNullableReferenceSchema() + { + return NullableReferenceStrategy == NullableReferenceSchemaStrategy.Explicit + ? GetNullableReferenceSchemaUsingExplicitNullType() + : GetNullableReferenceSchemaUsingImplicitNullType(); + } + + // This approach is supported in OAS starting from v3.1. See https://github.com/OAI/OpenAPI-Specification/issues/1368#issuecomment-580103688 + private static OpenApiSchema GetNullableReferenceSchemaUsingExplicitNullType() + { + return new() + { + Type = "null" + }; + } + + // This approach is supported starting from OAS v3.0. See https://github.com/OAI/OpenAPI-Specification/issues/1368#issuecomment-487314681 + private OpenApiSchema GetNullableReferenceSchemaUsingImplicitNullType() + { + if (_referenceSchemaForNullValue != null) + { + return _referenceSchemaForNullValue; + } + + var fullSchemaForNullValue = new OpenApiSchema + { + Nullable = true, + Not = new OpenApiSchema + { + AnyOf = new List + { + new() + { + Type = "string" + }, + new() + { + Type = "number" + }, + new() + { + Type = "boolean" + }, + new() + { + Type = "object" + }, + new() + { + Type = "array" + } + }, + Items = new OpenApiSchema() + } + }; + + _referenceSchemaForNullValue = _schemaRepositoryAccessor.Current.AddDefinition("null-value", fullSchemaForNullValue); + + return _referenceSchemaForNullValue; + } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/NullableReferenceSchemaStrategy.cs b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/NullableReferenceSchemaStrategy.cs new file mode 100644 index 0000000000..5580f776ee --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/NullableReferenceSchemaStrategy.cs @@ -0,0 +1,8 @@ +namespace JsonApiDotNetCore.OpenApi.SwaggerComponents +{ + internal enum NullableReferenceSchemaStrategy + { + Implicit, + Explicit + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ResourceFieldObjectSchemaBuilder.cs b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ResourceFieldObjectSchemaBuilder.cs new file mode 100644 index 0000000000..ab08ae3948 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ResourceFieldObjectSchemaBuilder.cs @@ -0,0 +1,243 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Reflection; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.RelationshipData; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace JsonApiDotNetCore.OpenApi.SwaggerComponents +{ + internal sealed class ResourceFieldObjectSchemaBuilder + { + private static readonly SchemaRepository ResourceSchemaRepository = new(); + + private readonly ResourceTypeInfo _resourceTypeInfo; + private readonly ISchemaRepositoryAccessor _schemaRepositoryAccessor; + private readonly SchemaGenerator _defaultSchemaGenerator; + private readonly JsonApiSchemaIdSelector _jsonApiSchemaIdSelector; + private readonly ResourceTypeSchemaGenerator _resourceTypeSchemaGenerator; + private readonly NullableReferenceSchemaGenerator _nullableReferenceSchemaGenerator; + private readonly IDictionary _schemasForResourceFields; + + public ResourceFieldObjectSchemaBuilder(ResourceTypeInfo resourceTypeInfo, ISchemaRepositoryAccessor schemaRepositoryAccessor, + SchemaGenerator defaultSchemaGenerator, JsonApiSchemaIdSelector jsonApiSchemaIdSelector, ResourceTypeSchemaGenerator resourceTypeSchemaGenerator) + { + ArgumentGuard.NotNull(resourceTypeInfo, nameof(resourceTypeInfo)); + ArgumentGuard.NotNull(schemaRepositoryAccessor, nameof(schemaRepositoryAccessor)); + ArgumentGuard.NotNull(defaultSchemaGenerator, nameof(defaultSchemaGenerator)); + ArgumentGuard.NotNull(jsonApiSchemaIdSelector, nameof(jsonApiSchemaIdSelector)); + ArgumentGuard.NotNull(resourceTypeSchemaGenerator, nameof(resourceTypeSchemaGenerator)); + + _resourceTypeInfo = resourceTypeInfo; + _schemaRepositoryAccessor = schemaRepositoryAccessor; + _defaultSchemaGenerator = defaultSchemaGenerator; + _jsonApiSchemaIdSelector = jsonApiSchemaIdSelector; + _resourceTypeSchemaGenerator = resourceTypeSchemaGenerator; + + _nullableReferenceSchemaGenerator = new NullableReferenceSchemaGenerator(schemaRepositoryAccessor); + _schemasForResourceFields = GetFieldSchemas(); + } + + private IDictionary GetFieldSchemas() + { + if (!ResourceSchemaRepository.TryLookupByType(_resourceTypeInfo.ResourceType, out OpenApiSchema referenceSchemaForResource)) + { + referenceSchemaForResource = _defaultSchemaGenerator.GenerateSchema(_resourceTypeInfo.ResourceType, ResourceSchemaRepository); + } + + OpenApiSchema fullSchemaForResource = ResourceSchemaRepository.Schemas[referenceSchemaForResource.Reference.Id]; + return fullSchemaForResource.Properties; + } + + public OpenApiSchema BuildAttributesObject(OpenApiSchema fullSchemaForResourceObject) + { + ArgumentGuard.NotNull(fullSchemaForResourceObject, nameof(fullSchemaForResourceObject)); + + OpenApiSchema fullSchemaForAttributesObject = fullSchemaForResourceObject.Properties[JsonApiObjectPropertyName.AttributesObject]; + + SetMembersOfAttributesObject(fullSchemaForAttributesObject); + + fullSchemaForAttributesObject.AdditionalPropertiesAllowed = false; + + if (fullSchemaForAttributesObject.Properties.Any()) + { + return GetReferenceSchemaForFieldObject(fullSchemaForAttributesObject, JsonApiObjectPropertyName.AttributesObject); + } + + return null; + } + + private void SetMembersOfAttributesObject(OpenApiSchema fullSchemaForAttributesObject) + { + AttrCapabilities requiredCapability = GetRequiredCapabilityForAttributes(_resourceTypeInfo.ResourceObjectOpenType); + + foreach ((string fieldName, OpenApiSchema resourceFieldSchema) in _schemasForResourceFields) + { + var matchingAttribute = _resourceTypeInfo.TryGetResourceFieldByName(fieldName); + + if (matchingAttribute != null && matchingAttribute.Capabilities.HasFlag(requiredCapability)) + { + AddAttributeSchemaToResourceObject(matchingAttribute, fullSchemaForAttributesObject, resourceFieldSchema); + + if (IsAttributeRequired(_resourceTypeInfo.ResourceObjectOpenType, matchingAttribute)) + { + fullSchemaForAttributesObject.Required.Add(matchingAttribute.PublicName); + } + } + } + } + + private static AttrCapabilities GetRequiredCapabilityForAttributes(Type resourceObjectOpenType) + { + return resourceObjectOpenType == typeof(ResourceResponseObject<>) ? AttrCapabilities.AllowView : + resourceObjectOpenType == typeof(ResourcePostRequestObject<>) ? AttrCapabilities.AllowCreate : + resourceObjectOpenType == typeof(ResourcePatchRequestObject<>) ? AttrCapabilities.AllowChange : throw new UnreachableCodeException(); + } + + private void AddAttributeSchemaToResourceObject(AttrAttribute attribute, OpenApiSchema attributesObjectSchema, OpenApiSchema resourceAttributeSchema) + { + if (resourceAttributeSchema.Reference != null && !_schemaRepositoryAccessor.Current.TryLookupByType(attribute.Property.PropertyType, out _)) + { + ExposeSchema(resourceAttributeSchema.Reference, attribute.Property.PropertyType); + } + + attributesObjectSchema.Properties.Add(attribute.PublicName, resourceAttributeSchema); + } + + private void ExposeSchema(OpenApiReference openApiReference, Type typeRepresentedBySchema) + { + OpenApiSchema fullSchema = ResourceSchemaRepository.Schemas[openApiReference.Id]; + _schemaRepositoryAccessor.Current.AddDefinition(openApiReference.Id, fullSchema); + _schemaRepositoryAccessor.Current.RegisterType(typeRepresentedBySchema, openApiReference.Id); + } + + private static bool IsAttributeRequired(Type resourceObjectOpenType, AttrAttribute matchingAttribute) + { + return resourceObjectOpenType == typeof(ResourcePostRequestObject<>) && matchingAttribute.Property.GetCustomAttribute() != null; + } + + private OpenApiSchema GetReferenceSchemaForFieldObject(OpenApiSchema fullSchema, string fieldObjectName) + { + // NSwag does not have proper support for using an inline schema for the attributes and relationships object in a resource object, see https://github.com/RicoSuter/NSwag/issues/3474. Once this issue has been resolved, we can remove this. + string resourceObjectSchemaId = _jsonApiSchemaIdSelector.GetSchemaId(_resourceTypeInfo.ResourceObjectType); + string fieldObjectSchemaId = resourceObjectSchemaId.Replace(JsonApiObjectPropertyName.Data, fieldObjectName); + + return _schemaRepositoryAccessor.Current.AddDefinition(fieldObjectSchemaId, fullSchema); + } + + public OpenApiSchema BuildRelationshipsObject(OpenApiSchema fullSchemaForResourceObject) + { + ArgumentGuard.NotNull(fullSchemaForResourceObject, nameof(fullSchemaForResourceObject)); + + OpenApiSchema fullSchemaForRelationshipsObject = fullSchemaForResourceObject.Properties[JsonApiObjectPropertyName.RelationshipsObject]; + + SetMembersOfRelationshipsObject(fullSchemaForRelationshipsObject); + + fullSchemaForRelationshipsObject.AdditionalPropertiesAllowed = false; + + if (fullSchemaForRelationshipsObject.Properties.Any()) + { + return GetReferenceSchemaForFieldObject(fullSchemaForRelationshipsObject, JsonApiObjectPropertyName.RelationshipsObject); + } + + return null; + } + + private void SetMembersOfRelationshipsObject(OpenApiSchema fullSchemaForRelationshipsObject) + { + foreach (string fieldName in _schemasForResourceFields.Keys) + { + var matchingRelationship = _resourceTypeInfo.TryGetResourceFieldByName(fieldName); + + if (matchingRelationship != null) + { + EnsureResourceIdentifierObjectSchemaExists(matchingRelationship); + AddRelationshipDataSchemaToResourceObject(matchingRelationship, fullSchemaForRelationshipsObject); + } + } + } + + private void EnsureResourceIdentifierObjectSchemaExists(RelationshipAttribute relationship) + { + Type resourceIdentifierObjectType = typeof(ResourceIdentifierObject<>).MakeGenericType(relationship.RightType); + + if (!ResourceIdentifierObjectSchemaExists(resourceIdentifierObjectType)) + { + GenerateResourceIdentifierObjectSchema(resourceIdentifierObjectType); + } + } + + private bool ResourceIdentifierObjectSchemaExists(Type resourceIdentifierObjectType) + { + return _schemaRepositoryAccessor.Current.TryLookupByType(resourceIdentifierObjectType, out _); + } + + private void GenerateResourceIdentifierObjectSchema(Type resourceIdentifierObjectType) + { + OpenApiSchema referenceSchemaForResourceIdentifierObject = + _defaultSchemaGenerator.GenerateSchema(resourceIdentifierObjectType, _schemaRepositoryAccessor.Current); + + OpenApiSchema fullSchemaForResourceIdentifierObject = + _schemaRepositoryAccessor.Current.Schemas[referenceSchemaForResourceIdentifierObject.Reference.Id]; + + Type resourceType = resourceIdentifierObjectType.GetGenericArguments()[0]; + fullSchemaForResourceIdentifierObject.Properties[JsonApiObjectPropertyName.Type] = _resourceTypeSchemaGenerator.Get(resourceType); + } + + private void AddRelationshipDataSchemaToResourceObject(RelationshipAttribute relationship, OpenApiSchema relationshipObjectSchema) + { + Type relationshipDataType = GetRelationshipDataType(relationship, _resourceTypeInfo.ResourceObjectOpenType); + + OpenApiSchema referenceSchemaForRelationshipData = TryGetReferenceSchemaForRelationshipData(relationshipDataType) ?? + CreateRelationshipDataObjectSchema(relationship, relationshipDataType); + + relationshipObjectSchema.Properties.Add(relationship.PublicName, referenceSchemaForRelationshipData); + } + + private static Type GetRelationshipDataType(RelationshipAttribute relationship, Type resourceObjectType) + { + if (resourceObjectType.GetGenericTypeDefinition().IsAssignableTo(typeof(ResourceResponseObject<>))) + { + return relationship is HasOneAttribute + ? typeof(ToOneRelationshipResponseData<>).MakeGenericType(relationship.RightType) + : typeof(ToManyRelationshipResponseData<>).MakeGenericType(relationship.RightType); + } + + return relationship is HasOneAttribute + ? typeof(ToOneRelationshipRequestData<>).MakeGenericType(relationship.RightType) + : typeof(ToManyRelationshipRequestData<>).MakeGenericType(relationship.RightType); + } + + private OpenApiSchema TryGetReferenceSchemaForRelationshipData(Type relationshipDataType) + { + _schemaRepositoryAccessor.Current.TryLookupByType(relationshipDataType, out OpenApiSchema referenceSchemaForRelationshipData); + return referenceSchemaForRelationshipData; + } + + private OpenApiSchema CreateRelationshipDataObjectSchema(RelationshipAttribute relationship, Type relationshipDataType) + { + OpenApiSchema referenceSchema = _defaultSchemaGenerator.GenerateSchema(relationshipDataType, _schemaRepositoryAccessor.Current); + + OpenApiSchema fullSchema = _schemaRepositoryAccessor.Current.Schemas[referenceSchema.Reference.Id]; + + Type relationshipDataOpenType = relationshipDataType.GetGenericTypeDefinition(); + + if (relationshipDataOpenType == typeof(ToOneRelationshipResponseData<>) || relationshipDataOpenType == typeof(ToManyRelationshipResponseData<>)) + { + fullSchema.Required.Remove(JsonApiObjectPropertyName.Data); + } + + if (relationship is HasOneAttribute) + { + fullSchema.Properties[JsonApiObjectPropertyName.Data] = + _nullableReferenceSchemaGenerator.GenerateSchema(fullSchema.Properties[JsonApiObjectPropertyName.Data]); + } + + return referenceSchema; + } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ResourceObjectSchemaGenerator.cs b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ResourceObjectSchemaGenerator.cs new file mode 100644 index 0000000000..e4c3791e20 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ResourceObjectSchemaGenerator.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.ResourceObjects; +using Microsoft.OpenApi.Models; +using Newtonsoft.Json.Serialization; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace JsonApiDotNetCore.OpenApi.SwaggerComponents +{ + internal sealed class ResourceObjectSchemaGenerator + { + private readonly SchemaGenerator _defaultSchemaGenerator; + private readonly IResourceContextProvider _resourceContextProvider; + private readonly ISchemaRepositoryAccessor _schemaRepositoryAccessor; + private readonly ResourceTypeSchemaGenerator _resourceTypeSchemaGenerator; + private readonly bool _allowClientGeneratedIds; + private readonly Func _createFieldObjectBuilderFactory; + + public ResourceObjectSchemaGenerator(SchemaGenerator defaultSchemaGenerator, IResourceContextProvider resourceContextProvider, + IJsonApiOptions jsonApiOptions, ISchemaRepositoryAccessor schemaRepositoryAccessor) + { + ArgumentGuard.NotNull(defaultSchemaGenerator, nameof(defaultSchemaGenerator)); + ArgumentGuard.NotNull(resourceContextProvider, nameof(resourceContextProvider)); + ArgumentGuard.NotNull(jsonApiOptions, nameof(jsonApiOptions)); + ArgumentGuard.NotNull(schemaRepositoryAccessor, nameof(schemaRepositoryAccessor)); + + _defaultSchemaGenerator = defaultSchemaGenerator; + _resourceContextProvider = resourceContextProvider; + _schemaRepositoryAccessor = schemaRepositoryAccessor; + + _resourceTypeSchemaGenerator = new ResourceTypeSchemaGenerator(schemaRepositoryAccessor, resourceContextProvider); + _allowClientGeneratedIds = jsonApiOptions.AllowClientGeneratedIds; + + _createFieldObjectBuilderFactory = CreateFieldObjectBuilderFactory(defaultSchemaGenerator, resourceContextProvider, jsonApiOptions, + schemaRepositoryAccessor, _resourceTypeSchemaGenerator); + } + + private static Func CreateFieldObjectBuilderFactory(SchemaGenerator defaultSchemaGenerator, + IResourceContextProvider resourceContextProvider, IJsonApiOptions jsonApiOptions, ISchemaRepositoryAccessor schemaRepositoryAccessor, + ResourceTypeSchemaGenerator resourceTypeSchemaGenerator) + { + NamingStrategy namingStrategy = ((DefaultContractResolver)jsonApiOptions.SerializerSettings.ContractResolver)!.NamingStrategy; + ResourceNameFormatter resourceNameFormatter = new(namingStrategy); + var jsonApiSchemaIdSelector = new JsonApiSchemaIdSelector(resourceNameFormatter, resourceContextProvider); + + return resourceTypeInfo => new ResourceFieldObjectSchemaBuilder(resourceTypeInfo, schemaRepositoryAccessor, defaultSchemaGenerator, + jsonApiSchemaIdSelector, resourceTypeSchemaGenerator); + } + + public OpenApiSchema GenerateSchema(Type resourceObjectType) + { + ArgumentGuard.NotNull(resourceObjectType, nameof(resourceObjectType)); + + (OpenApiSchema fullSchemaForResourceObject, OpenApiSchema referenceSchemaForResourceObject) = EnsureSchemasExist(resourceObjectType); + + var resourceTypeInfo = ResourceTypeInfo.Create(resourceObjectType, _resourceContextProvider); + ResourceFieldObjectSchemaBuilder fieldObjectBuilder = _createFieldObjectBuilderFactory(resourceTypeInfo); + + RemoveResourceIdIfPostResourceObject(resourceTypeInfo.ResourceObjectOpenType, fullSchemaForResourceObject); + + SetResourceType(fullSchemaForResourceObject, resourceTypeInfo.ResourceType); + + SetResourceAttributes(fullSchemaForResourceObject, fieldObjectBuilder); + + SetResourceRelationships(fullSchemaForResourceObject, fieldObjectBuilder); + + ReorderMembers(fullSchemaForResourceObject, new[] + { + JsonApiObjectPropertyName.Type, + JsonApiObjectPropertyName.Id, + JsonApiObjectPropertyName.AttributesObject, + JsonApiObjectPropertyName.RelationshipsObject, + JsonApiObjectPropertyName.LinksObject, + JsonApiObjectPropertyName.MetaObject + }); + + return referenceSchemaForResourceObject; + } + + private (OpenApiSchema fullSchema, OpenApiSchema referenceSchema) EnsureSchemasExist(Type resourceObjectType) + { + if (!_schemaRepositoryAccessor.Current.TryLookupByType(resourceObjectType, out OpenApiSchema referenceSchema)) + { + referenceSchema = _defaultSchemaGenerator.GenerateSchema(resourceObjectType, _schemaRepositoryAccessor.Current); + } + + OpenApiSchema fullSchema = _schemaRepositoryAccessor.Current.Schemas[referenceSchema.Reference.Id]; + + return (fullSchema, referenceSchema); + } + + private void RemoveResourceIdIfPostResourceObject(Type resourceObjectOpenType, OpenApiSchema fullSchemaForResourceObject) + { + if (resourceObjectOpenType == typeof(ResourcePostRequestObject<>) && !_allowClientGeneratedIds) + { + fullSchemaForResourceObject.Required.Remove(JsonApiObjectPropertyName.Id); + fullSchemaForResourceObject.Properties.Remove(JsonApiObjectPropertyName.Id); + } + } + + private void SetResourceType(OpenApiSchema fullSchemaForResourceObject, Type resourceType) + { + fullSchemaForResourceObject.Properties[JsonApiObjectPropertyName.Type] = _resourceTypeSchemaGenerator.Get(resourceType); + } + + private static void SetResourceAttributes(OpenApiSchema fullSchemaForResourceObject, ResourceFieldObjectSchemaBuilder builder) + { + OpenApiSchema fullSchemaForAttributesObject = builder.BuildAttributesObject(fullSchemaForResourceObject); + + if (fullSchemaForAttributesObject != null) + { + fullSchemaForResourceObject.Properties[JsonApiObjectPropertyName.AttributesObject] = fullSchemaForAttributesObject; + } + else + { + fullSchemaForResourceObject.Properties.Remove(JsonApiObjectPropertyName.AttributesObject); + } + } + + private static void SetResourceRelationships(OpenApiSchema fullSchemaForResourceObject, ResourceFieldObjectSchemaBuilder builder) + { + OpenApiSchema fullSchemaForRelationshipsObject = builder.BuildRelationshipsObject(fullSchemaForResourceObject); + + if (fullSchemaForRelationshipsObject != null) + { + fullSchemaForResourceObject.Properties[JsonApiObjectPropertyName.RelationshipsObject] = fullSchemaForRelationshipsObject; + } + else + { + fullSchemaForResourceObject.Properties.Remove(JsonApiObjectPropertyName.RelationshipsObject); + } + } + + private static void ReorderMembers(OpenApiSchema fullSchemaForResourceObject, IEnumerable orderedMembers) + { + var reorderedMembers = new Dictionary(); + + foreach (string member in orderedMembers) + { + if (fullSchemaForResourceObject.Properties.ContainsKey(member)) + { + reorderedMembers[member] = fullSchemaForResourceObject.Properties[member]; + } + } + + fullSchemaForResourceObject.Properties = reorderedMembers; + } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ResourceTypeInfo.cs b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ResourceTypeInfo.cs new file mode 100644 index 0000000000..5dc13b24b8 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ResourceTypeInfo.cs @@ -0,0 +1,45 @@ +using System; +using System.Linq; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.OpenApi.SwaggerComponents +{ + internal sealed class ResourceTypeInfo + { + private readonly ResourceContext _resourceContext; + + public Type ResourceObjectType { get; } + public Type ResourceObjectOpenType { get; } + public Type ResourceType { get; } + + private ResourceTypeInfo(Type resourceObjectType, Type resourceObjectOpenType, Type resourceType, ResourceContext resourceContext) + { + _resourceContext = resourceContext; + + ResourceObjectType = resourceObjectType; + ResourceObjectOpenType = resourceObjectOpenType; + ResourceType = resourceType; + } + + public static ResourceTypeInfo Create(Type resourceObjectType, IResourceContextProvider resourceContextProvider) + { + ArgumentGuard.NotNull(resourceObjectType, nameof(resourceObjectType)); + ArgumentGuard.NotNull(resourceContextProvider, nameof(resourceContextProvider)); + + Type resourceObjectOpenType = resourceObjectType.GetGenericTypeDefinition(); + Type resourceType = resourceObjectType.GenericTypeArguments[0]; + ResourceContext resourceContext = resourceContextProvider.GetResourceContext(resourceType); + + return new ResourceTypeInfo(resourceObjectType, resourceObjectOpenType, resourceType, resourceContext); + } + + public TResourceFieldAttribute TryGetResourceFieldByName(string publicName) + where TResourceFieldAttribute : ResourceFieldAttribute + { + ArgumentGuard.NotNullNorEmpty(publicName, nameof(publicName)); + + return (TResourceFieldAttribute)_resourceContext.Fields.FirstOrDefault(field => field is TResourceFieldAttribute && field.PublicName == publicName); + } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ResourceTypeSchemaGenerator.cs b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ResourceTypeSchemaGenerator.cs new file mode 100644 index 0000000000..2ceba85e18 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ResourceTypeSchemaGenerator.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using JsonApiDotNetCore.Configuration; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; + +namespace JsonApiDotNetCore.OpenApi.SwaggerComponents +{ + internal sealed class ResourceTypeSchemaGenerator + { + private readonly ISchemaRepositoryAccessor _schemaRepositoryAccessor; + private readonly IResourceContextProvider _resourceContextProvider; + private readonly Dictionary _resourceTypeSchemaCache = new(); + + public ResourceTypeSchemaGenerator(ISchemaRepositoryAccessor schemaRepositoryAccessor, IResourceContextProvider resourceContextProvider) + { + ArgumentGuard.NotNull(schemaRepositoryAccessor, nameof(schemaRepositoryAccessor)); + ArgumentGuard.NotNull(resourceContextProvider, nameof(resourceContextProvider)); + + _schemaRepositoryAccessor = schemaRepositoryAccessor; + _resourceContextProvider = resourceContextProvider; + } + + public OpenApiSchema Get(Type resourceType) + { + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + + if (_resourceTypeSchemaCache.TryGetValue(resourceType, out OpenApiSchema referenceSchema)) + { + return referenceSchema; + } + + ResourceContext resourceContext = _resourceContextProvider.GetResourceContext(resourceType); + + var fullSchema = new OpenApiSchema + { + Type = "string", + Enum = new List + { + new OpenApiString(resourceContext.PublicName) + } + }; + + referenceSchema = new OpenApiSchema + { + Reference = new OpenApiReference + { + Id = $"{resourceContext.PublicName}-resource-type", + Type = ReferenceType.Schema + } + }; + + _schemaRepositoryAccessor.Current.AddDefinition(referenceSchema.Reference.Id, fullSchema); + _resourceTypeSchemaCache.Add(resourceContext.ResourceType, referenceSchema); + + return referenceSchema; + } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/SchemaRepositoryAccessor.cs b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/SchemaRepositoryAccessor.cs new file mode 100644 index 0000000000..e0a909f68a --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/SchemaRepositoryAccessor.cs @@ -0,0 +1,29 @@ +using System; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace JsonApiDotNetCore.OpenApi.SwaggerComponents +{ + internal sealed class SchemaRepositoryAccessor : ISchemaRepositoryAccessor + { + private SchemaRepository _schemaRepository; + + public SchemaRepository Current + { + get + { + if (_schemaRepository == null) + { + throw new InvalidOperationException("SchemaRepository unavailable."); + } + + return _schemaRepository; + } + set + { + ArgumentGuard.NotNull(value, nameof(Current)); + + _schemaRepository = value; + } + } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/UnreachableCodeException.cs b/src/JsonApiDotNetCore.OpenApi/UnreachableCodeException.cs new file mode 100644 index 0000000000..bdb05a7e86 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/UnreachableCodeException.cs @@ -0,0 +1,12 @@ +using System; + +namespace JsonApiDotNetCore.OpenApi +{ + internal sealed class UnreachableCodeException : Exception + { + public UnreachableCodeException() + : base("This code should not be reachable.") + { + } + } +} diff --git a/test/OpenApiClientTests/LegacyClient/ApiResponse.cs b/test/OpenApiClientTests/LegacyClient/ApiResponse.cs new file mode 100644 index 0000000000..73406fad09 --- /dev/null +++ b/test/OpenApiClientTests/LegacyClient/ApiResponse.cs @@ -0,0 +1,33 @@ +using System; +using System.Threading.Tasks; +using JsonApiDotNetCore.OpenApi.Client; +using OpenApiClientTests.LegacyClient.GeneratedCode; + +#pragma warning disable AV1008 // Class should not be static + +namespace OpenApiClientTests.LegacyClient +{ + internal static class ApiResponse + { + public static async Task TranslateAsync(Func> operation) + { + // Workaround for https://github.com/RicoSuter/NSwag/issues/2499 + + ArgumentGuard.NotNull(operation, nameof(operation)); + + try + { + return await operation(); + } + catch (ApiException exception) + { + if (exception.StatusCode != 204) + { + throw; + } + + return default; + } + } + } +} diff --git a/test/OpenApiClientTests/LegacyClient/ClientAttributeRegistrationLifeTimeTests.cs b/test/OpenApiClientTests/LegacyClient/ClientAttributeRegistrationLifeTimeTests.cs new file mode 100644 index 0000000000..f9e48c5da0 --- /dev/null +++ b/test/OpenApiClientTests/LegacyClient/ClientAttributeRegistrationLifeTimeTests.cs @@ -0,0 +1,426 @@ +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using OpenApiClientTests.LegacyClient.GeneratedCode; +using TestBuildingBlocks; +using Xunit; + +namespace OpenApiClientTests.LegacyClient +{ + public sealed class ClientAttributeRegistrationLifetimeTests + { + [Fact] + public async Task Disposed_attribute_registration_for_document_does_not_affect_request() + { + // Arrange + using var wrapper = FakeHttpClientWrapper.Create(HttpStatusCode.NoContent, null); + IOpenApiClient apiClient = new OpenApiClient(wrapper.HttpClient); + + const string airplaneId = "XUuiP"; + + var requestDocument = new AirplanePatchRequestDocument + { + Data = new AirplaneDataInPatchRequest + { + Id = airplaneId, + Type = AirplanesResourceType.Airplanes, + Attributes = new AirplaneAttributesInPatchRequest() + } + }; + + using (apiClient.RegisterAttributesForRequestDocument(requestDocument, + airplane => airplane.AirtimeInHours)) + { + _ = await ApiResponse.TranslateAsync(async () => await apiClient.PatchAirplaneAsync(airplaneId, requestDocument)); + } + + wrapper.ChangeResponse(HttpStatusCode.NoContent, null); + + // Act + _ = await ApiResponse.TranslateAsync(async () => await apiClient.PatchAirplaneAsync(airplaneId, requestDocument)); + + // Assert + wrapper.RequestBody.Should().BeJson(@"{ + ""data"": { + ""type"": ""airplanes"", + ""id"": """ + airplaneId + @""", + ""attributes"": { + ""is-in-maintenance"": false + } + } +}"); + } + + [Fact] + public async Task Attribute_registration_can_be_used_for_multiple_requests() + { + // Arrange + using var wrapper = FakeHttpClientWrapper.Create(HttpStatusCode.NoContent, null); + IOpenApiClient apiClient = new OpenApiClient(wrapper.HttpClient); + + const string airplaneId = "XUuiP"; + + var requestDocument = new AirplanePatchRequestDocument + { + Data = new AirplaneDataInPatchRequest + { + Id = airplaneId, + Type = AirplanesResourceType.Airplanes, + Attributes = new AirplaneAttributesInPatchRequest + { + AirtimeInHours = 100 + } + } + }; + + using (apiClient.RegisterAttributesForRequestDocument(requestDocument, + airplane => airplane.AirtimeInHours)) + { + _ = await ApiResponse.TranslateAsync(async () => await apiClient.PatchAirplaneAsync(airplaneId, requestDocument)); + + wrapper.ChangeResponse(HttpStatusCode.NoContent, null); + + requestDocument.Data.Attributes.AirtimeInHours = null; + + // Act + _ = await ApiResponse.TranslateAsync(async () => await apiClient.PatchAirplaneAsync(airplaneId, requestDocument)); + } + + // Assert + wrapper.RequestBody.Should().BeJson(@"{ + ""data"": { + ""type"": ""airplanes"", + ""id"": """ + airplaneId + @""", + ""attributes"": { + ""airtime-in-hours"": null + } + } +}"); + } + + [Fact] + public async Task Request_is_unaffected_by_attribute_registration_for_different_document_of_same_type() + { + // Arrange + using var wrapper = FakeHttpClientWrapper.Create(HttpStatusCode.NoContent, null); + IOpenApiClient apiClient = new OpenApiClient(wrapper.HttpClient); + + const string airplaneId1 = "XUuiP"; + + var requestDocument1 = new AirplanePatchRequestDocument + { + Data = new AirplaneDataInPatchRequest + { + Id = airplaneId1, + Type = AirplanesResourceType.Airplanes, + Attributes = new AirplaneAttributesInPatchRequest() + } + }; + + const string airplaneId2 = "DJy1u"; + + var requestDocument2 = new AirplanePatchRequestDocument + { + Data = new AirplaneDataInPatchRequest + { + Id = airplaneId2, + Type = AirplanesResourceType.Airplanes, + Attributes = new AirplaneAttributesInPatchRequest() + } + }; + + using (apiClient.RegisterAttributesForRequestDocument(requestDocument1, + airplane => airplane.AirtimeInHours)) + { + using (apiClient.RegisterAttributesForRequestDocument(requestDocument2, + airplane => airplane.SerialNumber)) + { + } + + // Act + _ = await ApiResponse.TranslateAsync(async () => await apiClient.PatchAirplaneAsync(airplaneId2, requestDocument2)); + } + + // Assert + wrapper.RequestBody.Should().BeJson(@"{ + ""data"": { + ""type"": ""airplanes"", + ""id"": """ + airplaneId2 + @""", + ""attributes"": { + ""is-in-maintenance"": false + } + } +}"); + } + + [Fact] + public async Task Attribute_values_can_be_changed_after_attribute_registration() + { + // Arrange + using var wrapper = FakeHttpClientWrapper.Create(HttpStatusCode.NoContent, null); + IOpenApiClient apiClient = new OpenApiClient(wrapper.HttpClient); + + const string airplaneId = "XUuiP"; + + var requestDocument = new AirplanePatchRequestDocument + { + Data = new AirplaneDataInPatchRequest + { + Id = airplaneId, + Type = AirplanesResourceType.Airplanes, + Attributes = new AirplaneAttributesInPatchRequest + { + IsInMaintenance = true + } + } + }; + + using (apiClient.RegisterAttributesForRequestDocument(requestDocument, + airplane => airplane.IsInMaintenance)) + { + requestDocument.Data.Attributes.IsInMaintenance = false; + + // Act + _ = await ApiResponse.TranslateAsync(async () => await apiClient.PatchAirplaneAsync(airplaneId, requestDocument)); + } + + // Assert + wrapper.RequestBody.Should().BeJson(@"{ + ""data"": { + ""type"": ""airplanes"", + ""id"": """ + airplaneId + @""", + ""attributes"": { + ""is-in-maintenance"": false + } + } +}"); + } + + [Fact] + public async Task Attribute_registration_is_unaffected_by_successive_attribute_registration_for_document_of_different_type() + { + // Arrange + using var wrapper = FakeHttpClientWrapper.Create(HttpStatusCode.NoContent, null); + IOpenApiClient apiClient = new OpenApiClient(wrapper.HttpClient); + + const string airplaneId1 = "XUuiP"; + + var requestDocument1 = new AirplanePatchRequestDocument + { + Data = new AirplaneDataInPatchRequest + { + Id = airplaneId1, + Type = AirplanesResourceType.Airplanes, + Attributes = new AirplaneAttributesInPatchRequest() + } + }; + + var requestDocument2 = new AirplanePostRequestDocument + { + Data = new AirplaneDataInPostRequest + { + Type = AirplanesResourceType.Airplanes, + Attributes = new AirplaneAttributesInPostRequest() + } + }; + + using (apiClient.RegisterAttributesForRequestDocument(requestDocument1, + airplane => airplane.IsInMaintenance)) + { + using (apiClient.RegisterAttributesForRequestDocument(requestDocument2, + airplane => airplane.AirtimeInHours)) + { + // Act + _ = await ApiResponse.TranslateAsync(async () => await apiClient.PatchAirplaneAsync(airplaneId1, requestDocument1)); + } + } + + // Assert + wrapper.RequestBody.Should().BeJson(@"{ + ""data"": { + ""type"": ""airplanes"", + ""id"": """ + airplaneId1 + @""", + ""attributes"": { + ""is-in-maintenance"": false + } + } +}"); + } + + [Fact] + public async Task Attribute_registration_is_unaffected_by_preceding_disposed_attribute_registration_for_different_document_of_same_type() + { + // Arrange + using var wrapper = FakeHttpClientWrapper.Create(HttpStatusCode.NoContent, null); + IOpenApiClient apiClient = new OpenApiClient(wrapper.HttpClient); + + const string airplaneId1 = "XUuiP"; + + var requestDocument1 = new AirplanePatchRequestDocument + { + Data = new AirplaneDataInPatchRequest + { + Id = airplaneId1, + Type = AirplanesResourceType.Airplanes, + Attributes = new AirplaneAttributesInPatchRequest() + } + }; + + using (apiClient.RegisterAttributesForRequestDocument(requestDocument1, + airplane => airplane.AirtimeInHours)) + { + _ = await ApiResponse.TranslateAsync(async () => await apiClient.PatchAirplaneAsync(airplaneId1, requestDocument1)); + } + + const string airplaneId2 = "DJy1u"; + + var requestDocument2 = new AirplanePatchRequestDocument + { + Data = new AirplaneDataInPatchRequest + { + Id = airplaneId2, + Type = AirplanesResourceType.Airplanes, + Attributes = new AirplaneAttributesInPatchRequest + { + ManufacturedInCity = "Everett" + } + } + }; + + wrapper.ChangeResponse(HttpStatusCode.NoContent, null); + + using (apiClient.RegisterAttributesForRequestDocument(requestDocument2, + airplane => airplane.SerialNumber)) + { + // Act + _ = await ApiResponse.TranslateAsync(async () => await apiClient.PatchAirplaneAsync(airplaneId2, requestDocument2)); + } + + // Assert + wrapper.RequestBody.Should().BeJson(@"{ + ""data"": { + ""type"": ""airplanes"", + ""id"": """ + airplaneId2 + @""", + ""attributes"": { + ""serial-number"": null, + ""manufactured-in-city"": ""Everett"" + } + } +}"); + } + + [Fact] + public async Task Attribute_registration_is_unaffected_by_preceding_disposed_attribute_registration_for_document_of_different_type() + { + // Arrange + using var wrapper = FakeHttpClientWrapper.Create(HttpStatusCode.NoContent, null); + IOpenApiClient apiClient = new OpenApiClient(wrapper.HttpClient); + + var requestDocument1 = new AirplanePostRequestDocument + { + Data = new AirplaneDataInPostRequest + { + Type = AirplanesResourceType.Airplanes, + Attributes = new AirplaneAttributesInPostRequest() + } + }; + + using (apiClient.RegisterAttributesForRequestDocument(requestDocument1, + airplane => airplane.AirtimeInHours)) + { + _ = await ApiResponse.TranslateAsync(async () => await apiClient.PostAirplaneAsync(requestDocument1)); + } + + const string airplaneId = "DJy1u"; + + var requestDocument2 = new AirplanePatchRequestDocument + { + Data = new AirplaneDataInPatchRequest + { + Id = airplaneId, + Type = AirplanesResourceType.Airplanes, + Attributes = new AirplaneAttributesInPatchRequest + { + ManufacturedInCity = "Everett" + } + } + }; + + wrapper.ChangeResponse(HttpStatusCode.NoContent, null); + + using (apiClient.RegisterAttributesForRequestDocument(requestDocument2, + airplane => airplane.SerialNumber)) + { + // Act + _ = await ApiResponse.TranslateAsync(async () => await apiClient.PatchAirplaneAsync(airplaneId, requestDocument2)); + } + + // Assert + wrapper.RequestBody.Should().BeJson(@"{ + ""data"": { + ""type"": ""airplanes"", + ""id"": """ + airplaneId + @""", + ""attributes"": { + ""serial-number"": null, + ""manufactured-in-city"": ""Everett"" + } + } +}"); + } + + [Fact] + public async Task Attribute_registration_is_unaffected_by_preceding_attribute_registration_for_different_document_of_same_type() + { + // Arrange + using var wrapper = FakeHttpClientWrapper.Create(HttpStatusCode.NoContent, null); + IOpenApiClient apiClient = new OpenApiClient(wrapper.HttpClient); + + const string airplaneId1 = "XUuiP"; + + var requestDocument1 = new AirplanePatchRequestDocument + { + Data = new AirplaneDataInPatchRequest + { + Id = airplaneId1, + Type = AirplanesResourceType.Airplanes, + Attributes = new AirplaneAttributesInPatchRequest() + } + }; + + const string airplaneId2 = "DJy1u"; + + var requestDocument2 = new AirplanePatchRequestDocument + { + Data = new AirplaneDataInPatchRequest + { + Id = airplaneId2, + Type = AirplanesResourceType.Airplanes, + Attributes = new AirplaneAttributesInPatchRequest() + } + }; + + using (apiClient.RegisterAttributesForRequestDocument(requestDocument1, + airplane => airplane.SerialNumber)) + { + using (apiClient.RegisterAttributesForRequestDocument(requestDocument2, + airplane => airplane.IsInMaintenance, airplane => airplane.AirtimeInHours)) + { + // Act + _ = await ApiResponse.TranslateAsync(async () => await apiClient.PatchAirplaneAsync(airplaneId2, requestDocument2)); + } + } + + // Assert + wrapper.RequestBody.Should().BeJson(@"{ + ""data"": { + ""type"": ""airplanes"", + ""id"": """ + airplaneId2 + @""", + ""attributes"": { + ""airtime-in-hours"": null, + ""is-in-maintenance"": false + } + } +}"); + } + } +} diff --git a/test/OpenApiClientTests/LegacyClient/FakeHttpClientWrapper.cs b/test/OpenApiClientTests/LegacyClient/FakeHttpClientWrapper.cs new file mode 100644 index 0000000000..e5cfe6a687 --- /dev/null +++ b/test/OpenApiClientTests/LegacyClient/FakeHttpClientWrapper.cs @@ -0,0 +1,106 @@ +using System; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.OpenApi.Client; + +namespace OpenApiClientTests.LegacyClient +{ + /// + /// Enables to inject an outgoing response body and inspect the incoming request. + /// + internal sealed class FakeHttpClientWrapper : IDisposable + { + private readonly FakeHttpMessageHandler _handler; + + public HttpClient HttpClient { get; } + public HttpRequestMessage Request => _handler.Request; + public string RequestBody => _handler.RequestBody; + + private FakeHttpClientWrapper(HttpClient httpClient, FakeHttpMessageHandler handler) + { + HttpClient = httpClient; + _handler = handler; + } + + public static FakeHttpClientWrapper Create(HttpStatusCode statusCode, string responseBody) + { + HttpResponseMessage response = CreateResponse(statusCode, responseBody); + var handler = new FakeHttpMessageHandler(); + handler.SetResponse(response); + + var httpClient = new HttpClient(handler) + { + BaseAddress = new Uri("http://localhost") + }; + + return new FakeHttpClientWrapper(httpClient, handler); + } + + public void ChangeResponse(HttpStatusCode statusCode, string responseBody) + { + HttpResponseMessage response = CreateResponse(statusCode, responseBody); + + _handler.SetResponse(response); + } + + private static HttpResponseMessage CreateResponse(HttpStatusCode statusCode, string responseBody) + { + var response = new HttpResponseMessage(statusCode); + + if (!string.IsNullOrEmpty(responseBody)) + { + response.Content = new StringContent(responseBody, Encoding.UTF8); + response.Content.Headers.ContentType = MediaTypeHeaderValue.Parse("application/vnd.api+json"); + } + + return response; + } + + public void Dispose() + { + HttpClient.Dispose(); + _handler.Dispose(); + } + + private sealed class FakeHttpMessageHandler : HttpMessageHandler + { + private HttpResponseMessage _response; + + public HttpRequestMessage Request { get; private set; } + public string RequestBody { get; private set; } + + public void SetResponse(HttpResponseMessage response) + { + ArgumentGuard.NotNull(response, nameof(response)); + + _response = response; + } + + protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken) + { + Request = request; + + // Capture the request body here, before it becomes inaccessible because the request has been disposed. + if (request.Content != null) + { + using Stream stream = request.Content.ReadAsStream(); + using var reader = new StreamReader(stream, Encoding.UTF8); + RequestBody = reader.ReadToEnd(); + } + + return _response; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + HttpResponseMessage response = Send(request, cancellationToken); + return Task.FromResult(response); + } + } + } +} diff --git a/test/OpenApiClientTests/LegacyClient/GeneratedCode/IOpenApiClient.cs b/test/OpenApiClientTests/LegacyClient/GeneratedCode/IOpenApiClient.cs new file mode 100644 index 0000000000..55cf1e855f --- /dev/null +++ b/test/OpenApiClientTests/LegacyClient/GeneratedCode/IOpenApiClient.cs @@ -0,0 +1,9 @@ +using JsonApiDotNetCore.OpenApi.Client; + +namespace OpenApiClientTests.LegacyClient.GeneratedCode +{ + // ReSharper disable once MemberCanBeInternal + public partial interface IOpenApiClient : IJsonApiClient + { + } +} diff --git a/test/OpenApiClientTests/LegacyClient/GeneratedCode/OpenApiClient.cs b/test/OpenApiClientTests/LegacyClient/GeneratedCode/OpenApiClient.cs new file mode 100644 index 0000000000..500d1354d2 --- /dev/null +++ b/test/OpenApiClientTests/LegacyClient/GeneratedCode/OpenApiClient.cs @@ -0,0 +1,15 @@ +using JsonApiDotNetCore.OpenApi.Client; +using Newtonsoft.Json; + +namespace OpenApiClientTests.LegacyClient.GeneratedCode +{ + internal partial class OpenApiClient : JsonApiClient + { + partial void UpdateJsonSerializerSettings(JsonSerializerSettings settings) + { + SetSerializerSettingsForJsonApi(settings); + + settings.Formatting = Formatting.Indented; + } + } +} diff --git a/test/OpenApiClientTests/LegacyClient/RequestTests.cs b/test/OpenApiClientTests/LegacyClient/RequestTests.cs new file mode 100644 index 0000000000..ff9f1bbe56 --- /dev/null +++ b/test/OpenApiClientTests/LegacyClient/RequestTests.cs @@ -0,0 +1,491 @@ +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using FluentAssertions; +using FluentAssertions.Common; +using FluentAssertions.Extensions; +using JsonApiDotNetCore.Middleware; +using Microsoft.Net.Http.Headers; +using OpenApiClientTests.LegacyClient.GeneratedCode; +using TestBuildingBlocks; +using Xunit; + +namespace OpenApiClientTests.LegacyClient +{ + public sealed class RequestTests + { + private const string HostPrefix = "http://localhost/api/v1/"; + + [Fact] + public async Task Getting_resource_collection_produces_expected_request() + { + // Arrange + using var wrapper = FakeHttpClientWrapper.Create(HttpStatusCode.NoContent, null); + IOpenApiClient apiClient = new OpenApiClient(wrapper.HttpClient); + + // Act + _ = await ApiResponse.TranslateAsync(async () => await apiClient.GetFlightCollectionAsync()); + + // Assert + wrapper.Request.Headers.GetValue(HeaderNames.Accept).Should().Be(HeaderConstants.MediaType); + wrapper.Request.Method.Should().Be(HttpMethod.Get); + wrapper.Request.RequestUri.Should().Be(HostPrefix + "flights"); + wrapper.RequestBody.Should().BeNull(); + } + + [Fact] + public async Task Getting_resource_produces_expected_request() + { + // Arrange + const string flightId = "ZvuH1"; + + using var wrapper = FakeHttpClientWrapper.Create(HttpStatusCode.NoContent, null); + IOpenApiClient apiClient = new OpenApiClient(wrapper.HttpClient); + + // Act + _ = await ApiResponse.TranslateAsync(async () => await apiClient.GetFlightAsync(flightId)); + + // Assert + wrapper.Request.Headers.GetValue(HeaderNames.Accept).Should().Be(HeaderConstants.MediaType); + wrapper.Request.Method.Should().Be(HttpMethod.Get); + wrapper.Request.RequestUri.Should().Be(HostPrefix + $"flights/{flightId}"); + wrapper.RequestBody.Should().BeNull(); + } + + [Fact] + public async Task Partial_posting_resource_with_selected_relationships_produces_expected_request() + { + // Arrange + using var wrapper = FakeHttpClientWrapper.Create(HttpStatusCode.NoContent, null); + IOpenApiClient apiClient = new OpenApiClient(wrapper.HttpClient); + + var requestDocument = new FlightPostRequestDocument + { + Data = new FlightDataInPostRequest + { + Type = FlightsResourceType.Flights, + Relationships = new FlightRelationshipsInPostRequest + { + Purser = new ToOneFlightAttendantRequestData() + } + } + }; + + // Act + _ = await ApiResponse.TranslateAsync(async () => await apiClient.PostFlightAsync(requestDocument)); + + // Assert + wrapper.Request.Headers.GetValue(HeaderNames.Accept).Should().Be(HeaderConstants.MediaType); + wrapper.Request.Method.Should().Be(HttpMethod.Post); + wrapper.Request.RequestUri.Should().Be(HostPrefix + "flights"); + wrapper.Request.Content.Should().NotBeNull(); + wrapper.Request.Content!.Headers.ContentType.Should().NotBeNull(); + wrapper.Request.Content!.Headers.ContentType!.ToString().Should().Be(HeaderConstants.MediaType); + + wrapper.RequestBody.Should().BeJson(@"{ + ""data"": { + ""type"": ""flights"", + ""relationships"": { + ""purser"": { + ""data"": null + } + } + } +}"); + } + + [Fact] + public async Task Partial_posting_resource_produces_expected_request() + { + // Arrange + using var wrapper = FakeHttpClientWrapper.Create(HttpStatusCode.NoContent, null); + IOpenApiClient apiClient = new OpenApiClient(wrapper.HttpClient); + + const char euroSign = '\x20AC'; + const char checkMark = '\x2713'; + const char capitalLWithStroke = '\x0141'; + + string specialCharacters = new(new[] + { + euroSign, + checkMark, + capitalLWithStroke + }); + + string name = "anAirplaneName " + specialCharacters; + + var requestDocument = new AirplanePostRequestDocument + { + Data = new AirplaneDataInPostRequest + { + Type = AirplanesResourceType.Airplanes, + Attributes = new AirplaneAttributesInPostRequest + { + Name = name, + AirtimeInHours = 800 + } + } + }; + + using (apiClient.RegisterAttributesForRequestDocument(requestDocument, + airplane => airplane.SerialNumber)) + { + // Act + _ = await ApiResponse.TranslateAsync(async () => await apiClient.PostAirplaneAsync(requestDocument)); + } + + // Assert + wrapper.Request.Headers.GetValue(HeaderNames.Accept).Should().Be(HeaderConstants.MediaType); + wrapper.Request.Method.Should().Be(HttpMethod.Post); + wrapper.Request.RequestUri.Should().Be(HostPrefix + "airplanes"); + wrapper.Request.Content.Should().NotBeNull(); + wrapper.Request.Content!.Headers.ContentType.Should().NotBeNull(); + wrapper.Request.Content!.Headers.ContentType!.ToString().Should().Be(HeaderConstants.MediaType); + + wrapper.RequestBody.Should().BeJson(@"{ + ""data"": { + ""type"": ""airplanes"", + ""attributes"": { + ""name"": """ + name + @""", + ""serial-number"": null, + ""airtime-in-hours"": 800 + } + } +}"); + } + + [Fact] + public async Task Partial_patching_resource_produces_expected_request() + { + // Arrange + const string airplaneId = "XUuiP"; + var lastServicedAt = 1.January(2021).At(15, 23, 5, 33).ToDateTimeOffset(4.Hours()); + + using var wrapper = FakeHttpClientWrapper.Create(HttpStatusCode.NoContent, null); + IOpenApiClient apiClient = new OpenApiClient(wrapper.HttpClient); + + var requestDocument = new AirplanePatchRequestDocument + { + Data = new AirplaneDataInPatchRequest + { + Id = airplaneId, + Type = AirplanesResourceType.Airplanes, + Attributes = new AirplaneAttributesInPatchRequest + { + LastServicedAt = lastServicedAt + } + } + }; + + using (apiClient.RegisterAttributesForRequestDocument(requestDocument, + airplane => airplane.SerialNumber, airplane => airplane.LastServicedAt, airplane => airplane.IsInMaintenance, + airplane => airplane.AirtimeInHours)) + { + // Act + _ = await ApiResponse.TranslateAsync(async () => await apiClient.PatchAirplaneAsync(airplaneId, requestDocument)); + } + + // Assert + wrapper.Request.Headers.GetValue(HeaderNames.Accept).Should().Be(HeaderConstants.MediaType); + wrapper.Request.Method.Should().Be(HttpMethod.Patch); + wrapper.Request.RequestUri.Should().Be(HostPrefix + $"airplanes/{airplaneId}"); + wrapper.Request.Content.Should().NotBeNull(); + wrapper.Request.Content!.Headers.ContentType.Should().NotBeNull(); + wrapper.Request.Content!.Headers.ContentType!.ToString().Should().Be(HeaderConstants.MediaType); + + wrapper.RequestBody.Should().BeJson(@"{ + ""data"": { + ""type"": ""airplanes"", + ""id"": ""XUuiP"", + ""attributes"": { + ""serial-number"": null, + ""airtime-in-hours"": null, + ""last-serviced-at"": ""2021-01-01T15:23:05.033+04:00"", + ""is-in-maintenance"": false + } + } +}"); + } + + [Fact] + public async Task Deleting_resource_produces_expected_request() + { + // Arrange + const string flightId = "ZvuH1"; + + using var wrapper = FakeHttpClientWrapper.Create(HttpStatusCode.NoContent, null); + IOpenApiClient apiClient = new OpenApiClient(wrapper.HttpClient); + + // Act + await apiClient.DeleteFlightAsync(flightId); + + // Assert + wrapper.Request.Method.Should().Be(HttpMethod.Delete); + wrapper.Request.RequestUri.Should().Be(HostPrefix + $"flights/{flightId}"); + wrapper.RequestBody.Should().BeNull(); + } + + [Fact] + public async Task Getting_secondary_resource_produces_expected_request() + { + // Arrange + const string flightId = "ZvuH1"; + + using var wrapper = FakeHttpClientWrapper.Create(HttpStatusCode.NoContent, null); + IOpenApiClient apiClient = new OpenApiClient(wrapper.HttpClient); + + // Act + _ = await ApiResponse.TranslateAsync(async () => await apiClient.GetFlightPurserAsync(flightId)); + + // Assert + wrapper.Request.Headers.GetValue(HeaderNames.Accept).Should().Be(HeaderConstants.MediaType); + wrapper.Request.Method.Should().Be(HttpMethod.Get); + wrapper.Request.RequestUri.Should().Be(HostPrefix + $"flights/{flightId}/purser"); + wrapper.RequestBody.Should().BeNull(); + } + + [Fact] + public async Task Getting_secondary_resources_produces_expected_request() + { + // Arrange + const string flightId = "ZvuH1"; + + using var wrapper = FakeHttpClientWrapper.Create(HttpStatusCode.NoContent, null); + IOpenApiClient apiClient = new OpenApiClient(wrapper.HttpClient); + + // Act + _ = await ApiResponse.TranslateAsync(async () => await apiClient.GetFlightCabinCrewMembersAsync(flightId)); + + // Assert + wrapper.Request.Headers.GetValue(HeaderNames.Accept).Should().Be(HeaderConstants.MediaType); + wrapper.Request.Method.Should().Be(HttpMethod.Get); + wrapper.Request.RequestUri.Should().Be(HostPrefix + $"flights/{flightId}/cabin-crew-members"); + wrapper.RequestBody.Should().BeNull(); + } + + [Fact] + public async Task Getting_ToOne_relationship_produces_expected_request() + { + // Arrange + const string flightId = "ZvuH1"; + + using var wrapper = FakeHttpClientWrapper.Create(HttpStatusCode.NoContent, null); + IOpenApiClient apiClient = new OpenApiClient(wrapper.HttpClient); + + // Act + _ = await ApiResponse.TranslateAsync(async () => await apiClient.GetFlightPurserRelationshipAsync(flightId)); + + // Assert + wrapper.Request.Headers.GetValue(HeaderNames.Accept).Should().Be(HeaderConstants.MediaType); + wrapper.Request.Method.Should().Be(HttpMethod.Get); + wrapper.Request.RequestUri.Should().Be(HostPrefix + $"flights/{flightId}/relationships/purser"); + wrapper.RequestBody.Should().BeNull(); + } + + [Fact] + public async Task Patching_ToOne_relationship_produces_expected_request() + { + // Arrange + const string flightId = "ZvuH1"; + + using var wrapper = FakeHttpClientWrapper.Create(HttpStatusCode.NoContent, null); + IOpenApiClient apiClient = new OpenApiClient(wrapper.HttpClient); + + var requestDocument = new ToOneFlightAttendantRequestData + { + Data = new FlightAttendantIdentifier + { + Id = "bBJHu", + Type = FlightAttendantsResourceType.FlightAttendants + } + }; + + // Act + await apiClient.PatchFlightPurserRelationshipAsync(flightId, requestDocument); + + // Assert + wrapper.Request.Method.Should().Be(HttpMethod.Patch); + wrapper.Request.RequestUri.Should().Be(HostPrefix + $"flights/{flightId}/relationships/purser"); + wrapper.Request.Content.Should().NotBeNull(); + wrapper.Request.Content!.Headers.ContentType.Should().NotBeNull(); + wrapper.Request.Content!.Headers.ContentType!.ToString().Should().Be(HeaderConstants.MediaType); + + wrapper.RequestBody.Should().BeJson(@"{ + ""data"": { + ""type"": ""flight-attendants"", + ""id"": ""bBJHu"" + } +}"); + } + + [Fact] + public async Task Getting_ToMany_relationship_produces_expected_request() + { + // Arrange + const string flightId = "ZvuH1"; + + using var wrapper = FakeHttpClientWrapper.Create(HttpStatusCode.NoContent, null); + IOpenApiClient apiClient = new OpenApiClient(wrapper.HttpClient); + + // Act + _ = await ApiResponse.TranslateAsync(async () => await apiClient.GetFlightCabinCrewMembersRelationshipAsync(flightId)); + + // Assert + wrapper.Request.Headers.GetValue(HeaderNames.Accept).Should().Be(HeaderConstants.MediaType); + wrapper.Request.Method.Should().Be(HttpMethod.Get); + wrapper.Request.RequestUri.Should().Be(HostPrefix + $"flights/{flightId}/relationships/cabin-crew-members"); + wrapper.RequestBody.Should().BeNull(); + } + + [Fact] + public async Task Posting_ToMany_relationship_produces_expected_request() + { + // Arrange + const string flightId = "ZvuH1"; + + using var wrapper = FakeHttpClientWrapper.Create(HttpStatusCode.NoContent, null); + IOpenApiClient apiClient = new OpenApiClient(wrapper.HttpClient); + + var requestDocument = new ToManyFlightAttendantRequestData + { + Data = new List + { + new() + { + Type = FlightAttendantsResourceType.FlightAttendants, + Id = "bBJHu" + }, + new() + { + Type = FlightAttendantsResourceType.FlightAttendants, + Id = "NInmX" + } + } + }; + + // Act + await apiClient.PostFlightCabinCrewMembersRelationshipAsync(flightId, requestDocument); + + // Assert + wrapper.Request.Method.Should().Be(HttpMethod.Post); + wrapper.Request.RequestUri.Should().Be(HostPrefix + $"flights/{flightId}/relationships/cabin-crew-members"); + wrapper.Request.Content.Should().NotBeNull(); + wrapper.Request.Content!.Headers.ContentType.Should().NotBeNull(); + wrapper.Request.Content!.Headers.ContentType!.ToString().Should().Be(HeaderConstants.MediaType); + + wrapper.RequestBody.Should().BeJson(@"{ + ""data"": [ + { + ""type"": ""flight-attendants"", + ""id"": ""bBJHu"" + }, + { + ""type"": ""flight-attendants"", + ""id"": ""NInmX"" + } + ] +}"); + } + + [Fact] + public async Task Patching_ToMany_relationship_produces_expected_request() + { + // Arrange + const string flightId = "ZvuH1"; + + using var wrapper = FakeHttpClientWrapper.Create(HttpStatusCode.NoContent, null); + IOpenApiClient apiClient = new OpenApiClient(wrapper.HttpClient); + + var requestDocument = new ToManyFlightAttendantRequestData + { + Data = new List + { + new() + { + Id = "bBJHu", + Type = FlightAttendantsResourceType.FlightAttendants + }, + new() + { + Id = "NInmX", + Type = FlightAttendantsResourceType.FlightAttendants + } + } + }; + + // Act + await apiClient.PatchFlightCabinCrewMembersRelationshipAsync(flightId, requestDocument); + + // Assert + wrapper.Request.Method.Should().Be(HttpMethod.Patch); + wrapper.Request.RequestUri.Should().Be(HostPrefix + $"flights/{flightId}/relationships/cabin-crew-members"); + wrapper.Request.Content.Should().NotBeNull(); + wrapper.Request.Content!.Headers.ContentType.Should().NotBeNull(); + wrapper.Request.Content!.Headers.ContentType!.ToString().Should().Be(HeaderConstants.MediaType); + + wrapper.RequestBody.Should().BeJson(@"{ + ""data"": [ + { + ""type"": ""flight-attendants"", + ""id"": ""bBJHu"" + }, + { + ""type"": ""flight-attendants"", + ""id"": ""NInmX"" + } + ] +}"); + } + + [Fact] + public async Task Deleting_ToMany_relationship_produces_expected_request() + { + // Arrange + const string flightId = "ZvuH1"; + + using var wrapper = FakeHttpClientWrapper.Create(HttpStatusCode.NoContent, null); + IOpenApiClient apiClient = new OpenApiClient(wrapper.HttpClient); + + var requestDocument = new ToManyFlightAttendantRequestData + { + Data = new List + { + new() + { + Id = "bBJHu", + Type = FlightAttendantsResourceType.FlightAttendants + }, + new() + { + Id = "NInmX", + Type = FlightAttendantsResourceType.FlightAttendants + } + } + }; + + // Act + await apiClient.DeleteFlightCabinCrewMembersRelationshipAsync(flightId, requestDocument); + + // Assert + wrapper.Request.Method.Should().Be(HttpMethod.Delete); + wrapper.Request.RequestUri.Should().Be(HostPrefix + $"flights/{flightId}/relationships/cabin-crew-members"); + wrapper.Request.Content.Should().NotBeNull(); + wrapper.Request.Content!.Headers.ContentType.Should().NotBeNull(); + wrapper.Request.Content!.Headers.ContentType!.ToString().Should().Be(HeaderConstants.MediaType); + + wrapper.RequestBody.Should().BeJson(@"{ + ""data"": [ + { + ""type"": ""flight-attendants"", + ""id"": ""bBJHu"" + }, + { + ""type"": ""flight-attendants"", + ""id"": ""NInmX"" + } + ] +}"); + } + } +} diff --git a/test/OpenApiClientTests/LegacyClient/ResponseTests.cs b/test/OpenApiClientTests/LegacyClient/ResponseTests.cs new file mode 100644 index 0000000000..dfa5891dca --- /dev/null +++ b/test/OpenApiClientTests/LegacyClient/ResponseTests.cs @@ -0,0 +1,601 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using FluentAssertions.Specialized; +using OpenApiClientTests.LegacyClient.GeneratedCode; +using Xunit; + +namespace OpenApiClientTests.LegacyClient +{ + public sealed class ResponseTests + { + private const string HostPrefix = "http://localhost/api/v1/"; + + [Fact] + public async Task Getting_resource_collection_translates_response() + { + // Arrange + const string flightId = "ZvuH1"; + const string flightDestination = "Amsterdam"; + const string flightServiceOnBoard = "Movies"; + const string flightDepartsAt = "2014-11-25T00:00:00"; + const string documentMetaValue = "1"; + const string flightMetaValue = "https://api.jsonapi.net/docs/#get-flights"; + const string purserMetaValue = "https://api.jsonapi.net/docs/#get-flight-purser"; + const string cabinCrewMembersMetaValue = "https://api.jsonapi.net/docs/#get-flight-cabin-crew-members"; + const string passengersMetaValue = "https://api.jsonapi.net/docs/#get-flight-passengers"; + const string topLevelLink = HostPrefix + "flights"; + const string flightResourceLink = topLevelLink + "/" + flightId; + + const string responseBody = @"{ + ""meta"": { + ""total-resources"": """ + documentMetaValue + @""" + }, + ""links"": { + ""self"": """ + topLevelLink + @""", + ""first"": """ + topLevelLink + @""", + ""last"": """ + topLevelLink + @""" + }, + ""data"": [ + { + ""type"": ""flights"", + ""id"": """ + flightId + @""", + ""attributes"": { + ""final-destination"": """ + flightDestination + @""", + ""stop-over-destination"": null, + ""operated-by"": ""DeltaAirLines"", + ""departs-at"": """ + flightDepartsAt + @""", + ""arrives-at"": null, + ""services-on-board"": [ + """ + flightServiceOnBoard + @""", + """", + null + ] + }, + ""relationships"": { + ""purser"": { + ""links"": { + ""self"": """ + flightResourceLink + @"/relationships/purser"", + ""related"": """ + flightResourceLink + @"/purser"" + }, + ""meta"": { + ""docs"": """ + purserMetaValue + @""" + } + }, + ""cabin-crew-members"": { + ""links"": { + ""self"": """ + flightResourceLink + @"/relationships/cabin-crew-members"", + ""related"": """ + flightResourceLink + @"/cabin-crew-members"" + }, + ""meta"": { + ""docs"": """ + cabinCrewMembersMetaValue + @""" + } + }, + ""passengers"": { + ""links"": { + ""self"": """ + flightResourceLink + @"/relationships/passengers"", + ""related"": """ + flightResourceLink + @"/passengers"" + }, + ""meta"": { + ""docs"": """ + passengersMetaValue + @""" + } + } + }, + ""links"": { + ""self"": """ + flightResourceLink + @""" + }, + ""meta"": { + ""docs"": """ + flightMetaValue + @""" + } + } + ] +}"; + + using var wrapper = FakeHttpClientWrapper.Create(HttpStatusCode.OK, responseBody); + IOpenApiClient apiClient = new OpenApiClient(wrapper.HttpClient); + + // Act + FlightCollectionResponseDocument document = await apiClient.GetFlightCollectionAsync(); + + // Assert + document.Jsonapi.Should().BeNull(); + document.Meta.Should().HaveCount(1); + document.Meta["total-resources"].Should().Be(documentMetaValue); + document.Links.Self.Should().Be(topLevelLink); + document.Links.First.Should().Be(topLevelLink); + document.Links.Last.Should().Be(topLevelLink); + document.Data.Should().HaveCount(1); + + FlightDataInResponse flight = document.Data.First(); + flight.Id.Should().Be(flightId); + flight.Type.Should().Be(FlightsResourceType.Flights); + flight.Links.Self.Should().Be(flightResourceLink); + flight.Meta.Should().HaveCount(1); + flight.Meta["docs"].Should().Be(flightMetaValue); + + flight.Attributes.FinalDestination.Should().Be(flightDestination); + flight.Attributes.StopOverDestination.Should().BeNull(); + flight.Attributes.ServicesOnBoard.Should().HaveCount(3); + flight.Attributes.ServicesOnBoard.ElementAt(0).Should().Be(flightServiceOnBoard); + flight.Attributes.ServicesOnBoard.ElementAt(1).Should().Be(string.Empty); + flight.Attributes.ServicesOnBoard.ElementAt(2).Should().BeNull(); + flight.Attributes.OperatedBy.Should().Be(Airline.DeltaAirLines); + flight.Attributes.DepartsAt.Should().Be(DateTimeOffset.Parse(flightDepartsAt, new CultureInfo("en-GB"))); + flight.Attributes.ArrivesAt.Should().BeNull(); + + flight.Relationships.Purser.Data.Should().BeNull(); + flight.Relationships.Purser.Links.Self.Should().Be(flightResourceLink + "/relationships/purser"); + flight.Relationships.Purser.Links.Related.Should().Be(flightResourceLink + "/purser"); + flight.Relationships.Purser.Meta.Should().HaveCount(1); + flight.Relationships.Purser.Meta["docs"].Should().Be(purserMetaValue); + + flight.Relationships.CabinCrewMembers.Data.Should().BeNull(); + flight.Relationships.CabinCrewMembers.Links.Self.Should().Be(flightResourceLink + "/relationships/cabin-crew-members"); + flight.Relationships.CabinCrewMembers.Links.Related.Should().Be(flightResourceLink + "/cabin-crew-members"); + flight.Relationships.CabinCrewMembers.Meta.Should().HaveCount(1); + flight.Relationships.CabinCrewMembers.Meta["docs"].Should().Be(cabinCrewMembersMetaValue); + + flight.Relationships.Passengers.Data.Should().BeNull(); + flight.Relationships.Passengers.Links.Self.Should().Be(flightResourceLink + "/relationships/passengers"); + flight.Relationships.Passengers.Links.Related.Should().Be(flightResourceLink + "/passengers"); + flight.Relationships.Passengers.Meta.Should().HaveCount(1); + flight.Relationships.Passengers.Meta["docs"].Should().Be(passengersMetaValue); + } + + [Fact] + public async Task Getting_resource_translates_response() + { + // Arrange + const string flightId = "ZvuH1"; + const string departsAtInZuluTime = "2021-06-08T12:53:30.554Z"; + const string arrivesAtWithUtcOffset = "2019-02-20T11:56:33.0721266+01:00"; + + const string responseBody = @"{ + ""links"": { + ""self"": """ + HostPrefix + @"flights/" + flightId + @"&fields[flights]=departs-at,arrives-at"" + }, + ""data"": { + ""type"": ""flights"", + ""id"": """ + flightId + @""", + ""attributes"": { + ""departs-at"": """ + departsAtInZuluTime + @""", + ""arrives-at"": """ + arrivesAtWithUtcOffset + @""" + }, + ""links"": { + ""self"": """ + HostPrefix + @"flights/" + flightId + @""" + } + } +}"; + + using var wrapper = FakeHttpClientWrapper.Create(HttpStatusCode.OK, responseBody); + IOpenApiClient apiClient = new OpenApiClient(wrapper.HttpClient); + + // Act + FlightPrimaryResponseDocument document = await apiClient.GetFlightAsync(flightId); + + // Assert + document.Jsonapi.Should().BeNull(); + document.Meta.Should().BeNull(); + document.Data.Meta.Should().BeNull(); + document.Data.Relationships.Should().BeNull(); + document.Data.Attributes.DepartsAt.Should().Be(DateTimeOffset.Parse(departsAtInZuluTime)); + document.Data.Attributes.ArrivesAt.Should().Be(DateTimeOffset.Parse(arrivesAtWithUtcOffset)); + document.Data.Attributes.ServicesOnBoard.Should().BeNull(); + document.Data.Attributes.FinalDestination.Should().BeNull(); + document.Data.Attributes.StopOverDestination.Should().BeNull(); + document.Data.Attributes.OperatedBy.Should().Be(default(Airline)); + } + + [Fact] + public async Task Getting_unknown_resource_translates_error_response() + { + // Arrange + const string flightId = "ZvuH1"; + + const string responseBody = @"{ + ""errors"": [ + { + ""id"": ""f1a520ac-02a0-466b-94ea-86cbaa86f02f"", + ""status"": ""404"", + ""destination"": ""The requested resource does not exist."", + ""detail"": ""Resource of type 'flights' with ID '" + flightId + @"' does not exist."" + } + ] +}"; + + using var wrapper = FakeHttpClientWrapper.Create(HttpStatusCode.NotFound, responseBody); + IOpenApiClient apiClient = new OpenApiClient(wrapper.HttpClient); + + // Act + Func> action = async () => await apiClient.GetFlightAsync(flightId); + + // Assert + ExceptionAssertions assertion = await action.Should().ThrowExactlyAsync(); + ApiException exception = assertion.Subject.Single(); + + exception.StatusCode.Should().Be((int)HttpStatusCode.NotFound); + exception.Response.Should().Be(responseBody); + } + + [Fact] + public async Task Posting_resource_translates_response() + { + // Arrange + const string flightId = "ZvuH1"; + const string flightAttendantId = "bBJHu"; + + const string responseBody = @"{ + ""links"": { + ""self"": """ + HostPrefix + @"flights/" + flightId + @"&fields[flights]&include=purser,cabin-crew-members,passengers"" + }, + ""data"": { + ""type"": ""flights"", + ""id"": """ + flightId + @""", + ""relationships"": { + ""purser"": { + ""links"": { + ""self"": """ + HostPrefix + @"flights/" + flightId + @"/relationships/purser"", + ""related"": """ + HostPrefix + @"flights/" + flightId + @"/purser"" + }, + ""data"": null + }, + ""cabin-crew-members"": { + ""links"": { + ""self"": """ + HostPrefix + @"flights/" + flightId + @"/relationships/cabin-crew-members"", + ""related"": """ + HostPrefix + @"flights/" + flightId + @"/cabin-crew-members"" + }, + ""data"": [ + { + ""type"": ""flight-attendants"", + ""id"": """ + flightAttendantId + @""" + } + ], + }, + ""passengers"": { + ""links"": { + ""self"": """ + HostPrefix + @"flights/" + flightId + @"/relationships/passengers"", + ""related"": """ + HostPrefix + @"flights/" + flightId + @"/passengers"" + }, + ""data"": [ ] + } + }, + ""links"": { + ""self"": """ + HostPrefix + @"flights/" + flightId + @"&fields[flights]&include=purser,cabin-crew-members,passengers"" + } + } +}"; + + using var wrapper = FakeHttpClientWrapper.Create(HttpStatusCode.Created, responseBody); + IOpenApiClient apiClient = new OpenApiClient(wrapper.HttpClient); + + // Act + FlightPrimaryResponseDocument document = await apiClient.PostFlightAsync(new FlightPostRequestDocument + { + Data = new FlightDataInPostRequest + { + Type = FlightsResourceType.Flights, + Relationships = new FlightRelationshipsInPostRequest + { + Purser = new ToOneFlightAttendantRequestData + { + Data = new FlightAttendantIdentifier + { + Id = "XxuIu", + Type = FlightAttendantsResourceType.FlightAttendants + } + } + } + } + }); + + // Assert + document.Data.Attributes.Should().BeNull(); + document.Data.Relationships.Purser.Data.Should().BeNull(); + document.Data.Relationships.CabinCrewMembers.Data.Should().HaveCount(1); + document.Data.Relationships.CabinCrewMembers.Data.First().Id.Should().Be(flightAttendantId); + document.Data.Relationships.CabinCrewMembers.Data.First().Type.Should().Be(FlightAttendantsResourceType.FlightAttendants); + document.Data.Relationships.Passengers.Data.Should().BeEmpty(); + } + + [Fact] + public async Task Patching_resource_with_side_effects_translates_response() + { + // Arrange + const string flightId = "ZvuH1"; + + const string responseBody = @"{ + ""links"": { + ""self"": """ + HostPrefix + @"flights/" + flightId + @"&fields[flights]"" + }, + ""data"": { + ""type"": ""flights"", + ""id"": """ + flightId + @""", + ""links"": { + ""self"": """ + HostPrefix + @"flights/" + flightId + @"&fields[flights]&include=purser,cabin-crew-members,passengers"" + } + } +}"; + + using var wrapper = FakeHttpClientWrapper.Create(HttpStatusCode.OK, responseBody); + IOpenApiClient apiClient = new OpenApiClient(wrapper.HttpClient); + + // Act + FlightPrimaryResponseDocument document = await apiClient.PatchFlightAsync(flightId, new FlightPatchRequestDocument + { + Data = new FlightDataInPatchRequest + { + Id = flightId, + Type = FlightsResourceType.Flights + } + }); + + // Assert + document.Data.Type.Should().Be(FlightsResourceType.Flights); + document.Data.Attributes.Should().BeNull(); + document.Data.Relationships.Should().BeNull(); + } + + [Fact] + public async Task Patching_resource_without_side_effects_translates_response() + { + // Arrange + const string flightId = "ZvuH1"; + using var wrapper = FakeHttpClientWrapper.Create(HttpStatusCode.NoContent, null); + IOpenApiClient apiClient = new OpenApiClient(wrapper.HttpClient); + + // Act + FlightPrimaryResponseDocument document = await ApiResponse.TranslateAsync(async () => await apiClient.PatchFlightAsync(flightId, + new FlightPatchRequestDocument + { + Data = new FlightDataInPatchRequest + { + Id = flightId, + Type = FlightsResourceType.Flights + } + })); + + // Assert + document.Should().BeNull(); + } + + [Fact] + public async Task Deleting_resource_produces_empty_response() + { + // Arrange + using var wrapper = FakeHttpClientWrapper.Create(HttpStatusCode.NoContent, null); + IOpenApiClient apiClient = new OpenApiClient(wrapper.HttpClient); + + // Act + Func action = async () => await apiClient.DeleteFlightAsync("ZvuH1"); + + // Assert + await action.Should().NotThrowAsync(); + } + + [Fact] + public async Task Getting_secondary_resource_translates_response() + { + // Arrange + const string flightId = "ZvuH1"; + + const string responseBody = @"{ + ""links"": { + ""self"": """ + HostPrefix + @"flights/" + flightId + @"/purser"", + ""first"": """ + HostPrefix + @"flights/" + flightId + @"/purser"", + ""last"": """ + HostPrefix + @"flights/" + flightId + @"/purser"" + }, + ""data"": null +}"; + + using var wrapper = FakeHttpClientWrapper.Create(HttpStatusCode.OK, responseBody); + IOpenApiClient apiClient = new OpenApiClient(wrapper.HttpClient); + + // Act + FlightAttendantSecondaryResponseDocument document = await apiClient.GetFlightPurserAsync(flightId); + + // Assert + document.Data.Should().BeNull(); + } + + [Fact] + public async Task Getting_secondary_resources_translates_response() + { + // Arrange + const string flightId = "ZvuH1"; + + const string responseBody = @"{ + ""links"": { + ""self"": """ + HostPrefix + @"flights/" + flightId + @"/cabin-crew-members"", + ""first"": """ + HostPrefix + @"flights/" + flightId + @"/cabin-crew-members"" + }, + ""data"": [ ] +}"; + + using var wrapper = FakeHttpClientWrapper.Create(HttpStatusCode.OK, responseBody); + IOpenApiClient apiClient = new OpenApiClient(wrapper.HttpClient); + + // Act + FlightAttendantCollectionResponseDocument document = await apiClient.GetFlightCabinCrewMembersAsync(flightId); + + // Assert + document.Data.Should().BeEmpty(); + } + + [Fact] + public async Task Getting_ToOne_relationship_translates_response() + { + // Arrange + const string flightId = "ZvuH1"; + const string purserId = "bBJHu"; + + const string responseBody = @"{ + ""links"": { + ""self"": """ + HostPrefix + @"flights/" + flightId + @"/relationships/purser"", + ""related"": """ + HostPrefix + @"flights/" + flightId + @"/relationships/purser"" + }, + ""data"": { + ""type"": ""flight-attendants"", + ""id"": """ + purserId + @""" + } +}"; + + using var wrapper = FakeHttpClientWrapper.Create(HttpStatusCode.OK, responseBody); + IOpenApiClient apiClient = new OpenApiClient(wrapper.HttpClient); + + // Act + FlightAttendantIdentifierResponseDocument document = await apiClient.GetFlightPurserRelationshipAsync(flightId); + + // Assert + document.Data.Should().NotBeNull(); + document.Data.Id.Should().Be(purserId); + document.Data.Type.Should().Be(FlightAttendantsResourceType.FlightAttendants); + } + + [Fact] + public async Task Patching_ToOne_relationship_translates_response() + { + // Arrange + using var wrapper = FakeHttpClientWrapper.Create(HttpStatusCode.NoContent, null); + IOpenApiClient apiClient = new OpenApiClient(wrapper.HttpClient); + + // Act + await apiClient.PatchFlightPurserRelationshipAsync("ZvuH1", new ToOneFlightAttendantRequestData + { + Data = new FlightAttendantIdentifier + { + Id = "Adk2a", + Type = FlightAttendantsResourceType.FlightAttendants + } + }); + } + + [Fact] + public async Task Getting_ToMany_relationship_translates_response() + { + // Arrange + const string flightId = "ZvuH1"; + const string flightAttendantId1 = "bBJHu"; + const string flightAttendantId2 = "ZvuHNInmX1"; + + const string responseBody = @"{ + ""links"": { + ""self"": """ + HostPrefix + @"flights/" + flightId + @"/relationships/cabin-crew-members"", + ""related"": """ + HostPrefix + @"flights/" + flightId + @"/relationships/cabin-crew-members"", + ""first"": """ + HostPrefix + @"flights/" + flightId + @"/relationships/cabin-crew-members"" + }, + ""data"": [{ + ""type"": ""flight-attendants"", + ""id"": """ + flightAttendantId1 + @""" + }, + { + ""type"": ""flight-attendants"", + ""id"": """ + flightAttendantId2 + @""" + }] +}"; + + using var wrapper = FakeHttpClientWrapper.Create(HttpStatusCode.OK, responseBody); + IOpenApiClient apiClient = new OpenApiClient(wrapper.HttpClient); + + // Act + FlightAttendantIdentifierCollectionResponseDocument document = await apiClient.GetFlightCabinCrewMembersRelationshipAsync(flightId); + + // Assert + document.Data.Should().HaveCount(2); + document.Data.First().Id.Should().Be(flightAttendantId1); + document.Data.First().Type.Should().Be(FlightAttendantsResourceType.FlightAttendants); + document.Data.Last().Id.Should().Be(flightAttendantId2); + document.Data.Last().Type.Should().Be(FlightAttendantsResourceType.FlightAttendants); + } + + [Fact] + public async Task Posting_ToMany_relationship_produces_empty_response() + { + // Arrange + using var wrapper = FakeHttpClientWrapper.Create(HttpStatusCode.NoContent, null); + IOpenApiClient apiClient = new OpenApiClient(wrapper.HttpClient); + + // Act + Func action = async () => await apiClient.PostFlightCabinCrewMembersRelationshipAsync("ZvuH1", new ToManyFlightAttendantRequestData + { + Data = new List + { + new() + { + Id = "Adk2a", + Type = FlightAttendantsResourceType.FlightAttendants + }, + new() + { + Id = "Un37k", + Type = FlightAttendantsResourceType.FlightAttendants + } + } + }); + + // Assert + await action.Should().NotThrowAsync(); + } + + [Fact] + public async Task Patching_ToMany_relationship_produces_empty_response() + { + // Arrange + using var wrapper = FakeHttpClientWrapper.Create(HttpStatusCode.NoContent, null); + IOpenApiClient apiClient = new OpenApiClient(wrapper.HttpClient); + + // Act + Func action = async () => await apiClient.PatchFlightCabinCrewMembersRelationshipAsync("ZvuH1", new ToManyFlightAttendantRequestData + { + Data = new List + { + new() + { + Id = "Adk2a", + Type = FlightAttendantsResourceType.FlightAttendants + }, + new() + { + Id = "Un37k", + Type = FlightAttendantsResourceType.FlightAttendants + } + } + }); + + // Assert + await action.Should().NotThrowAsync(); + } + + [Fact] + public async Task Deleting_ToMany_relationship_produces_empty_response() + { + // Arrange + using var wrapper = FakeHttpClientWrapper.Create(HttpStatusCode.NoContent, null); + IOpenApiClient apiClient = new OpenApiClient(wrapper.HttpClient); + + // Act + Func action = async () => await apiClient.DeleteFlightCabinCrewMembersRelationshipAsync("ZvuH1", new ToManyFlightAttendantRequestData + { + Data = new List + { + new() + { + Id = "Adk2a", + Type = FlightAttendantsResourceType.FlightAttendants + }, + new() + { + Id = "Un37k", + Type = FlightAttendantsResourceType.FlightAttendants + } + } + }); + + // Assert + await action.Should().NotThrowAsync(); + } + } +} diff --git a/test/OpenApiClientTests/OpenApiClientTests.csproj b/test/OpenApiClientTests/OpenApiClientTests.csproj new file mode 100644 index 0000000000..3bd3b5da2d --- /dev/null +++ b/test/OpenApiClientTests/OpenApiClientTests.csproj @@ -0,0 +1,49 @@ + + + $(NetCoreAppVersion) + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + OpenApiClientTests.LegacyClient.GeneratedCode + OpenApiClient + NSwagCSharp + /UseBaseUrl:false /GenerateClientInterfaces:true /ClientClassAccessModifier:internal + + + + + + + + + + + + + + diff --git a/test/OpenApiTests/Airplane.cs b/test/OpenApiTests/Airplane.cs deleted file mode 100644 index 33a94b5013..0000000000 --- a/test/OpenApiTests/Airplane.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System; -using System.Collections.Generic; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace OpenApiTests -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Airplane : Identifiable - { - [Attr] - public int SeatingCapacity { get; set; } - - [Attr] - public DateTimeOffset ManufacturedAt { get; set; } - - [HasMany] - public ISet Flights { get; set; } - } -} diff --git a/test/OpenApiTests/Flight.cs b/test/OpenApiTests/Flight.cs deleted file mode 100644 index 9ec0443b8e..0000000000 --- a/test/OpenApiTests/Flight.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace OpenApiTests -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Flight : Identifiable - { - [Attr] - public string Destination { get; set; } - - [Attr] - public DateTimeOffset DepartsAt { get; set; } - } -} diff --git a/test/OpenApiTests/LegacyOpenApiIntegration/AircraftKind.cs b/test/OpenApiTests/LegacyOpenApiIntegration/AircraftKind.cs new file mode 100644 index 0000000000..463499110f --- /dev/null +++ b/test/OpenApiTests/LegacyOpenApiIntegration/AircraftKind.cs @@ -0,0 +1,13 @@ +using JetBrains.Annotations; + +namespace OpenApiTests.LegacyOpenApiIntegration +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public enum AircraftKind + { + Turboprops, + LightJet, + MidSizeJet, + JumboJet + } +} diff --git a/test/OpenApiTests/LegacyOpenApiIntegration/Airline.cs b/test/OpenApiTests/LegacyOpenApiIntegration/Airline.cs new file mode 100644 index 0000000000..959aec6fbc --- /dev/null +++ b/test/OpenApiTests/LegacyOpenApiIntegration/Airline.cs @@ -0,0 +1,12 @@ +using JetBrains.Annotations; + +namespace OpenApiTests.LegacyOpenApiIntegration +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public enum Airline : byte + { + DeltaAirLines, + LufthansaGroup, + AirFranceKlm + } +} diff --git a/test/OpenApiTests/LegacyOpenApiIntegration/Airplane.cs b/test/OpenApiTests/LegacyOpenApiIntegration/Airplane.cs new file mode 100644 index 0000000000..b086181524 --- /dev/null +++ b/test/OpenApiTests/LegacyOpenApiIntegration/Airplane.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace OpenApiTests.LegacyOpenApiIntegration +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class Airplane : Identifiable + { + [Attr(Capabilities = AttrCapabilities.AllowView | AttrCapabilities.AllowCreate | AttrCapabilities.AllowChange)] + [Required] + [MaxLength(255)] + public string Name { get; set; } + + [Attr(Capabilities = AttrCapabilities.AllowView | AttrCapabilities.AllowCreate | AttrCapabilities.AllowChange)] + [MaxLength(16)] + public string SerialNumber { get; set; } + + [Attr(Capabilities = AttrCapabilities.AllowView | AttrCapabilities.AllowCreate | AttrCapabilities.AllowChange)] + public int? AirtimeInHours { get; set; } + + [Attr(Capabilities = AttrCapabilities.AllowView | AttrCapabilities.AllowCreate | AttrCapabilities.AllowChange)] + public DateTime? LastServicedAt { get; set; } + + [Attr] + public DateTime ManufacturedAt { get; set; } + + [Attr(Capabilities = AttrCapabilities.AllowView | AttrCapabilities.AllowChange)] + public bool IsInMaintenance { get; set; } + + [Attr(Capabilities = AttrCapabilities.AllowView | AttrCapabilities.AllowChange)] + [MaxLength(85)] + public string ManufacturedInCity { get; set; } + + [Attr(Capabilities = AttrCapabilities.AllowView)] + public AircraftKind Kind { get; set; } + + [HasMany] + public ISet Flights { get; set; } + } +} diff --git a/test/OpenApiTests/AirplanesController.cs b/test/OpenApiTests/LegacyOpenApiIntegration/AirplanesController.cs similarity index 72% rename from test/OpenApiTests/AirplanesController.cs rename to test/OpenApiTests/LegacyOpenApiIntegration/AirplanesController.cs index 002f4b7b1e..3289ded0d4 100644 --- a/test/OpenApiTests/AirplanesController.cs +++ b/test/OpenApiTests/LegacyOpenApiIntegration/AirplanesController.cs @@ -3,11 +3,11 @@ using JsonApiDotNetCore.Services; using Microsoft.Extensions.Logging; -namespace OpenApiTests +namespace OpenApiTests.LegacyOpenApiIntegration { - public sealed class AirplanesController : JsonApiController + public sealed class AirplanesController : JsonApiController { - public AirplanesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) + public AirplanesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) : base(options, loggerFactory, resourceService) { } diff --git a/test/OpenApiTests/LegacyOpenApiIntegration/CabinArea.cs b/test/OpenApiTests/LegacyOpenApiIntegration/CabinArea.cs new file mode 100644 index 0000000000..fdee6cd686 --- /dev/null +++ b/test/OpenApiTests/LegacyOpenApiIntegration/CabinArea.cs @@ -0,0 +1,12 @@ +using JetBrains.Annotations; + +namespace OpenApiTests.LegacyOpenApiIntegration +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public enum CabinArea + { + FirstClass, + BusinessClass, + EconomyClass + } +} diff --git a/test/OpenApiTests/LegacyOpenApiIntegration/Flight.cs b/test/OpenApiTests/LegacyOpenApiIntegration/Flight.cs new file mode 100644 index 0000000000..f8dca69478 --- /dev/null +++ b/test/OpenApiTests/LegacyOpenApiIntegration/Flight.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace OpenApiTests.LegacyOpenApiIntegration +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class Flight : Identifiable + { + [Attr(Capabilities = AttrCapabilities.AllowView | AttrCapabilities.AllowChange)] + [Required] + [MaxLength(40)] + public string FinalDestination { get; set; } + + [Attr(Capabilities = AttrCapabilities.AllowView | AttrCapabilities.AllowChange)] + [MaxLength(2000)] + public string StopOverDestination { get; set; } + + [Attr(PublicName = "operated-by", Capabilities = AttrCapabilities.AllowView | AttrCapabilities.AllowChange)] + public Airline Airline { get; set; } + + [Attr] + public DateTime? DepartsAt { get; set; } + + [Attr] + public DateTime? ArrivesAt { get; set; } + + [HasMany] + public ISet CabinCrewMembers { get; set; } + + [HasOne] + public FlightAttendant Purser { get; set; } + + [Attr] + [NotMapped] + public ICollection ServicesOnBoard { get; set; } + + [HasMany] + public ICollection Passengers { get; set; } + } +} diff --git a/test/OpenApiTests/LegacyOpenApiIntegration/FlightAttendant.cs b/test/OpenApiTests/LegacyOpenApiIntegration/FlightAttendant.cs new file mode 100644 index 0000000000..520dcd8ffc --- /dev/null +++ b/test/OpenApiTests/LegacyOpenApiIntegration/FlightAttendant.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace OpenApiTests.LegacyOpenApiIntegration +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class FlightAttendant : Identifiable + { + [Attr(Capabilities = AttrCapabilities.AllowView | AttrCapabilities.AllowFilter)] + public override string Id { get; set; } + + [Attr(Capabilities = AttrCapabilities.None)] + public FlightAttendantExpertiseLevel ExpertiseLevel { get; set; } + + [Attr(Capabilities = AttrCapabilities.All)] + [Required] + [EmailAddress] + public string EmailAddress { get; set; } + + [Attr(Capabilities = AttrCapabilities.All)] + [Required] + [Range(18, 75)] + public int Age { get; set; } + + [Attr(Capabilities = AttrCapabilities.AllowView | AttrCapabilities.AllowCreate)] + [Required] + [Url] + public string ProfileImageUrl { get; set; } + + [Attr] + public long DistanceTraveledInKilometers { get; set; } + + [HasMany] + public ISet ScheduledForFlights { get; set; } + + [HasMany] + public ISet PurserOnFlights { get; set; } + } +} diff --git a/test/OpenApiTests/LegacyOpenApiIntegration/FlightAttendantExpertiseLevel.cs b/test/OpenApiTests/LegacyOpenApiIntegration/FlightAttendantExpertiseLevel.cs new file mode 100644 index 0000000000..c5ffe7cb49 --- /dev/null +++ b/test/OpenApiTests/LegacyOpenApiIntegration/FlightAttendantExpertiseLevel.cs @@ -0,0 +1,13 @@ +using JetBrains.Annotations; + +namespace OpenApiTests.LegacyOpenApiIntegration +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public enum FlightAttendantExpertiseLevel + { + Junior, + Intermediate, + Senior, + Purser + } +} diff --git a/test/OpenApiTests/LegacyOpenApiIntegration/FlightAttendantsController.cs b/test/OpenApiTests/LegacyOpenApiIntegration/FlightAttendantsController.cs new file mode 100644 index 0000000000..43adc8d0eb --- /dev/null +++ b/test/OpenApiTests/LegacyOpenApiIntegration/FlightAttendantsController.cs @@ -0,0 +1,15 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace OpenApiTests.LegacyOpenApiIntegration +{ + public sealed class FlightAttendantsController : JsonApiController + { + public FlightAttendantsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/OpenApiTests/FlightsController.cs b/test/OpenApiTests/LegacyOpenApiIntegration/FlightsController.cs similarity index 73% rename from test/OpenApiTests/FlightsController.cs rename to test/OpenApiTests/LegacyOpenApiIntegration/FlightsController.cs index 3b226673a2..6d243c893c 100644 --- a/test/OpenApiTests/FlightsController.cs +++ b/test/OpenApiTests/LegacyOpenApiIntegration/FlightsController.cs @@ -3,11 +3,11 @@ using JsonApiDotNetCore.Services; using Microsoft.Extensions.Logging; -namespace OpenApiTests +namespace OpenApiTests.LegacyOpenApiIntegration { - public sealed class FlightsController : JsonApiController + public sealed class FlightsController : JsonApiController { - public FlightsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) + public FlightsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) : base(options, loggerFactory, resourceService) { } diff --git a/test/OpenApiTests/LegacyOpenApiIntegration/LegacyIntegrationDbContext.cs b/test/OpenApiTests/LegacyOpenApiIntegration/LegacyIntegrationDbContext.cs new file mode 100644 index 0000000000..cf15a5a1ae --- /dev/null +++ b/test/OpenApiTests/LegacyOpenApiIntegration/LegacyIntegrationDbContext.cs @@ -0,0 +1,32 @@ +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; + +// @formatter:wrap_chained_method_calls chop_always +// @formatter:keep_existing_linebreaks true + +namespace OpenApiTests.LegacyOpenApiIntegration +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class LegacyIntegrationDbContext : DbContext + { + public DbSet Airplanes { get; set; } + public DbSet Flights { get; set; } + public DbSet FlightAttendants { get; set; } + + public LegacyIntegrationDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder builder) + { + builder.Entity() + .HasMany(flight => flight.CabinCrewMembers) + .WithMany(flightAttendant => flightAttendant.ScheduledForFlights); + + builder.Entity() + .HasOne(flight => flight.Purser) + .WithMany(flightAttendant => flightAttendant.PurserOnFlights); + } + } +} diff --git a/test/OpenApiTests/LegacyOpenApiIntegration/LegacyOpenApiIntegrationStartup.cs b/test/OpenApiTests/LegacyOpenApiIntegration/LegacyOpenApiIntegrationStartup.cs new file mode 100644 index 0000000000..02dbf96e25 --- /dev/null +++ b/test/OpenApiTests/LegacyOpenApiIntegration/LegacyOpenApiIntegrationStartup.cs @@ -0,0 +1,26 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.EntityFrameworkCore; +using Newtonsoft.Json.Serialization; + +namespace OpenApiTests.LegacyOpenApiIntegration +{ + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] + public sealed class LegacyOpenApiIntegrationStartup : OpenApiStartup + where TDbContext : DbContext + { + protected override void SetJsonApiOptions(JsonApiOptions options) + { + base.SetJsonApiOptions(options); + + options.Namespace = "api/v1"; + options.DefaultAttrCapabilities = AttrCapabilities.AllowView; + + options.SerializerSettings.ContractResolver = new DefaultContractResolver + { + NamingStrategy = new KebabCaseNamingStrategy() + }; + } + } +} diff --git a/test/OpenApiTests/OpenApiDocumentTests.cs b/test/OpenApiTests/LegacyOpenApiIntegration/LegacyOpenApiIntegrationTests.cs similarity index 82% rename from test/OpenApiTests/OpenApiDocumentTests.cs rename to test/OpenApiTests/LegacyOpenApiIntegration/LegacyOpenApiIntegrationTests.cs index 898987712b..313c78aae3 100644 --- a/test/OpenApiTests/OpenApiDocumentTests.cs +++ b/test/OpenApiTests/LegacyOpenApiIntegration/LegacyOpenApiIntegrationTests.cs @@ -7,21 +7,23 @@ using TestBuildingBlocks; using Xunit; -namespace OpenApiTests +namespace OpenApiTests.LegacyOpenApiIntegration { - public sealed class OpenApiDocumentTests : IntegrationTestContext, OpenApiDbContext> + public sealed class LegacyOpenApiIntegrationTests + : IntegrationTestContext, LegacyIntegrationDbContext> { - public OpenApiDocumentTests() + public LegacyOpenApiIntegrationTests() { UseController(); UseController(); + UseController(); } [Fact] public async Task Retrieved_document_matches_expected_document() { // Arrange - string embeddedResourceName = $"{nameof(OpenApiTests)}.swagger.json"; + string embeddedResourceName = $"{nameof(OpenApiTests)}.{nameof(LegacyOpenApiIntegration)}.swagger.json"; string expectedDocument = await LoadEmbeddedResourceAsync(embeddedResourceName); const string requestUrl = "swagger/v1/swagger.json"; diff --git a/test/OpenApiTests/LegacyOpenApiIntegration/Passenger.cs b/test/OpenApiTests/LegacyOpenApiIntegration/Passenger.cs new file mode 100644 index 0000000000..263393bac5 --- /dev/null +++ b/test/OpenApiTests/LegacyOpenApiIntegration/Passenger.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace OpenApiTests.LegacyOpenApiIntegration +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class Passenger : Identifiable + { + [Attr(PublicName = "document-number", Capabilities = AttrCapabilities.AllowCreate | AttrCapabilities.AllowChange)] + [Required] + [MaxLength(9)] + public string PassportNumber { get; set; } + + [Attr] + public string FullName { get; set; } + + [Attr] + public CabinArea CabinArea { get; set; } + } +} diff --git a/test/OpenApiTests/LegacyOpenApiIntegration/PassengersController.cs b/test/OpenApiTests/LegacyOpenApiIntegration/PassengersController.cs new file mode 100644 index 0000000000..ea056c4474 --- /dev/null +++ b/test/OpenApiTests/LegacyOpenApiIntegration/PassengersController.cs @@ -0,0 +1,15 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace OpenApiTests.LegacyOpenApiIntegration +{ + public sealed class PassengersController : JsonApiController + { + public PassengersController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/OpenApiTests/LegacyOpenApiIntegration/swagger.json b/test/OpenApiTests/LegacyOpenApiIntegration/swagger.json new file mode 100644 index 0000000000..3ed426bf24 --- /dev/null +++ b/test/OpenApiTests/LegacyOpenApiIntegration/swagger.json @@ -0,0 +1,3130 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "OpenApiTests", + "version": "1.0" + }, + "paths": { + "/api/v1/airplanes": { + "get": { + "tags": [ + "airplanes" + ], + "operationId": "get-airplane-collection", + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/airplane-collection-response-document" + } + } + } + } + } + }, + "head": { + "tags": [ + "airplanes" + ], + "operationId": "head-airplane-collection", + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/airplane-collection-response-document" + } + } + } + } + } + }, + "post": { + "tags": [ + "airplanes" + ], + "operationId": "post-airplane", + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/airplane-post-request-document" + } + } + } + }, + "responses": { + "201": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/airplane-primary-response-document" + } + } + } + }, + "204": { + "description": "Success" + } + } + } + }, + "/api/v1/airplanes/{id}": { + "get": { + "tags": [ + "airplanes" + ], + "operationId": "get-airplane", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/airplane-primary-response-document" + } + } + } + } + } + }, + "head": { + "tags": [ + "airplanes" + ], + "operationId": "head-airplane", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/airplane-primary-response-document" + } + } + } + } + } + }, + "patch": { + "tags": [ + "airplanes" + ], + "operationId": "patch-airplane", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/airplane-patch-request-document" + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/airplane-primary-response-document" + } + } + } + }, + "204": { + "description": "Success" + } + } + }, + "delete": { + "tags": [ + "airplanes" + ], + "operationId": "delete-airplane", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Success" + } + } + } + }, + "/api/v1/airplanes/{id}/flights": { + "get": { + "tags": [ + "airplanes" + ], + "operationId": "get-airplane-flights", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/flight-collection-response-document" + } + } + } + } + } + }, + "head": { + "tags": [ + "airplanes" + ], + "operationId": "head-airplane-flights", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/flight-collection-response-document" + } + } + } + } + } + } + }, + "/api/v1/airplanes/{id}/relationships/flights": { + "get": { + "tags": [ + "airplanes" + ], + "operationId": "get-airplane-flights-relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/flight-identifier-collection-response-document" + } + } + } + } + } + }, + "head": { + "tags": [ + "airplanes" + ], + "operationId": "head-airplane-flights-relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/flight-identifier-collection-response-document" + } + } + } + } + } + }, + "post": { + "tags": [ + "airplanes" + ], + "operationId": "post-airplane-flights-relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/to-many-flight-request-data" + } + } + } + }, + "responses": { + "204": { + "description": "Success" + } + } + }, + "patch": { + "tags": [ + "airplanes" + ], + "operationId": "patch-airplane-flights-relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/to-many-flight-request-data" + } + } + } + }, + "responses": { + "204": { + "description": "Success" + } + } + }, + "delete": { + "tags": [ + "airplanes" + ], + "operationId": "delete-airplane-flights-relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/to-many-flight-request-data" + } + } + } + }, + "responses": { + "204": { + "description": "Success" + } + } + } + }, + "/api/v1/flight-attendants": { + "get": { + "tags": [ + "flight-attendants" + ], + "operationId": "get-flight-attendant-collection", + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/flight-attendant-collection-response-document" + } + } + } + } + } + }, + "head": { + "tags": [ + "flight-attendants" + ], + "operationId": "head-flight-attendant-collection", + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/flight-attendant-collection-response-document" + } + } + } + } + } + }, + "post": { + "tags": [ + "flight-attendants" + ], + "operationId": "post-flight-attendant", + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/flight-attendant-post-request-document" + } + } + } + }, + "responses": { + "201": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/flight-attendant-primary-response-document" + } + } + } + }, + "204": { + "description": "Success" + } + } + } + }, + "/api/v1/flight-attendants/{id}": { + "get": { + "tags": [ + "flight-attendants" + ], + "operationId": "get-flight-attendant", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/flight-attendant-primary-response-document" + } + } + } + } + } + }, + "head": { + "tags": [ + "flight-attendants" + ], + "operationId": "head-flight-attendant", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/flight-attendant-primary-response-document" + } + } + } + } + } + }, + "patch": { + "tags": [ + "flight-attendants" + ], + "operationId": "patch-flight-attendant", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/flight-attendant-patch-request-document" + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/flight-attendant-primary-response-document" + } + } + } + }, + "204": { + "description": "Success" + } + } + }, + "delete": { + "tags": [ + "flight-attendants" + ], + "operationId": "delete-flight-attendant", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Success" + } + } + } + }, + "/api/v1/flight-attendants/{id}/purser-on-flights": { + "get": { + "tags": [ + "flight-attendants" + ], + "operationId": "get-flight-attendant-purser-on-flights", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/flight-collection-response-document" + } + } + } + } + } + }, + "head": { + "tags": [ + "flight-attendants" + ], + "operationId": "head-flight-attendant-purser-on-flights", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/flight-collection-response-document" + } + } + } + } + } + } + }, + "/api/v1/flight-attendants/{id}/relationships/purser-on-flights": { + "get": { + "tags": [ + "flight-attendants" + ], + "operationId": "get-flight-attendant-purser-on-flights-relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/flight-identifier-collection-response-document" + } + } + } + } + } + }, + "head": { + "tags": [ + "flight-attendants" + ], + "operationId": "head-flight-attendant-purser-on-flights-relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/flight-identifier-collection-response-document" + } + } + } + } + } + }, + "post": { + "tags": [ + "flight-attendants" + ], + "operationId": "post-flight-attendant-purser-on-flights-relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/to-many-flight-request-data" + } + } + } + }, + "responses": { + "204": { + "description": "Success" + } + } + }, + "patch": { + "tags": [ + "flight-attendants" + ], + "operationId": "patch-flight-attendant-purser-on-flights-relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/to-many-flight-request-data" + } + } + } + }, + "responses": { + "204": { + "description": "Success" + } + } + }, + "delete": { + "tags": [ + "flight-attendants" + ], + "operationId": "delete-flight-attendant-purser-on-flights-relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/to-many-flight-request-data" + } + } + } + }, + "responses": { + "204": { + "description": "Success" + } + } + } + }, + "/api/v1/flight-attendants/{id}/scheduled-for-flights": { + "get": { + "tags": [ + "flight-attendants" + ], + "operationId": "get-flight-attendant-scheduled-for-flights", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/flight-collection-response-document" + } + } + } + } + } + }, + "head": { + "tags": [ + "flight-attendants" + ], + "operationId": "head-flight-attendant-scheduled-for-flights", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/flight-collection-response-document" + } + } + } + } + } + } + }, + "/api/v1/flight-attendants/{id}/relationships/scheduled-for-flights": { + "get": { + "tags": [ + "flight-attendants" + ], + "operationId": "get-flight-attendant-scheduled-for-flights-relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/flight-identifier-collection-response-document" + } + } + } + } + } + }, + "head": { + "tags": [ + "flight-attendants" + ], + "operationId": "head-flight-attendant-scheduled-for-flights-relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/flight-identifier-collection-response-document" + } + } + } + } + } + }, + "post": { + "tags": [ + "flight-attendants" + ], + "operationId": "post-flight-attendant-scheduled-for-flights-relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/to-many-flight-request-data" + } + } + } + }, + "responses": { + "204": { + "description": "Success" + } + } + }, + "patch": { + "tags": [ + "flight-attendants" + ], + "operationId": "patch-flight-attendant-scheduled-for-flights-relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/to-many-flight-request-data" + } + } + } + }, + "responses": { + "204": { + "description": "Success" + } + } + }, + "delete": { + "tags": [ + "flight-attendants" + ], + "operationId": "delete-flight-attendant-scheduled-for-flights-relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/to-many-flight-request-data" + } + } + } + }, + "responses": { + "204": { + "description": "Success" + } + } + } + }, + "/api/v1/flights": { + "get": { + "tags": [ + "flights" + ], + "operationId": "get-flight-collection", + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/flight-collection-response-document" + } + } + } + } + } + }, + "head": { + "tags": [ + "flights" + ], + "operationId": "head-flight-collection", + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/flight-collection-response-document" + } + } + } + } + } + }, + "post": { + "tags": [ + "flights" + ], + "operationId": "post-flight", + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/flight-post-request-document" + } + } + } + }, + "responses": { + "201": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/flight-primary-response-document" + } + } + } + }, + "204": { + "description": "Success" + } + } + } + }, + "/api/v1/flights/{id}": { + "get": { + "tags": [ + "flights" + ], + "operationId": "get-flight", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/flight-primary-response-document" + } + } + } + } + } + }, + "head": { + "tags": [ + "flights" + ], + "operationId": "head-flight", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/flight-primary-response-document" + } + } + } + } + } + }, + "patch": { + "tags": [ + "flights" + ], + "operationId": "patch-flight", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/flight-patch-request-document" + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/flight-primary-response-document" + } + } + } + }, + "204": { + "description": "Success" + } + } + }, + "delete": { + "tags": [ + "flights" + ], + "operationId": "delete-flight", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Success" + } + } + } + }, + "/api/v1/flights/{id}/cabin-crew-members": { + "get": { + "tags": [ + "flights" + ], + "operationId": "get-flight-cabin-crew-members", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/flight-attendant-collection-response-document" + } + } + } + } + } + }, + "head": { + "tags": [ + "flights" + ], + "operationId": "head-flight-cabin-crew-members", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/flight-attendant-collection-response-document" + } + } + } + } + } + } + }, + "/api/v1/flights/{id}/relationships/cabin-crew-members": { + "get": { + "tags": [ + "flights" + ], + "operationId": "get-flight-cabin-crew-members-relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/flight-attendant-identifier-collection-response-document" + } + } + } + } + } + }, + "head": { + "tags": [ + "flights" + ], + "operationId": "head-flight-cabin-crew-members-relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/flight-attendant-identifier-collection-response-document" + } + } + } + } + } + }, + "post": { + "tags": [ + "flights" + ], + "operationId": "post-flight-cabin-crew-members-relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/to-many-flight-attendant-request-data" + } + } + } + }, + "responses": { + "204": { + "description": "Success" + } + } + }, + "patch": { + "tags": [ + "flights" + ], + "operationId": "patch-flight-cabin-crew-members-relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/to-many-flight-attendant-request-data" + } + } + } + }, + "responses": { + "204": { + "description": "Success" + } + } + }, + "delete": { + "tags": [ + "flights" + ], + "operationId": "delete-flight-cabin-crew-members-relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/to-many-flight-attendant-request-data" + } + } + } + }, + "responses": { + "204": { + "description": "Success" + } + } + } + }, + "/api/v1/flights/{id}/passengers": { + "get": { + "tags": [ + "flights" + ], + "operationId": "get-flight-passengers", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/passenger-collection-response-document" + } + } + } + } + } + }, + "head": { + "tags": [ + "flights" + ], + "operationId": "head-flight-passengers", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/passenger-collection-response-document" + } + } + } + } + } + } + }, + "/api/v1/flights/{id}/relationships/passengers": { + "get": { + "tags": [ + "flights" + ], + "operationId": "get-flight-passengers-relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/passenger-identifier-collection-response-document" + } + } + } + } + } + }, + "head": { + "tags": [ + "flights" + ], + "operationId": "head-flight-passengers-relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/passenger-identifier-collection-response-document" + } + } + } + } + } + }, + "post": { + "tags": [ + "flights" + ], + "operationId": "post-flight-passengers-relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/to-many-passenger-request-data" + } + } + } + }, + "responses": { + "204": { + "description": "Success" + } + } + }, + "patch": { + "tags": [ + "flights" + ], + "operationId": "patch-flight-passengers-relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/to-many-passenger-request-data" + } + } + } + }, + "responses": { + "204": { + "description": "Success" + } + } + }, + "delete": { + "tags": [ + "flights" + ], + "operationId": "delete-flight-passengers-relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/to-many-passenger-request-data" + } + } + } + }, + "responses": { + "204": { + "description": "Success" + } + } + } + }, + "/api/v1/flights/{id}/purser": { + "get": { + "tags": [ + "flights" + ], + "operationId": "get-flight-purser", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/flight-attendant-secondary-response-document" + } + } + } + } + } + }, + "head": { + "tags": [ + "flights" + ], + "operationId": "head-flight-purser", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/flight-attendant-secondary-response-document" + } + } + } + } + } + } + }, + "/api/v1/flights/{id}/relationships/purser": { + "get": { + "tags": [ + "flights" + ], + "operationId": "get-flight-purser-relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/flight-attendant-identifier-response-document" + } + } + } + } + } + }, + "head": { + "tags": [ + "flights" + ], + "operationId": "head-flight-purser-relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/flight-attendant-identifier-response-document" + } + } + } + } + } + }, + "patch": { + "tags": [ + "flights" + ], + "operationId": "patch-flight-purser-relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/to-one-flight-attendant-request-data" + } + } + } + }, + "responses": { + "204": { + "description": "Success" + } + } + } + } + }, + "components": { + "schemas": { + "aircraft-kind": { + "enum": [ + "Turboprops", + "LightJet", + "MidSizeJet", + "JumboJet" + ], + "type": "string" + }, + "airline": { + "enum": [ + "DeltaAirLines", + "LufthansaGroup", + "AirFranceKlm" + ], + "type": "string" + }, + "airplane-attributes-in-patch-request": { + "type": "object", + "properties": { + "name": { + "maxLength": 255, + "type": "string" + }, + "serial-number": { + "maxLength": 16, + "type": "string", + "nullable": true + }, + "airtime-in-hours": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "last-serviced-at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "is-in-maintenance": { + "type": "boolean" + }, + "manufactured-in-city": { + "maxLength": 85, + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "airplane-attributes-in-post-request": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "name": { + "maxLength": 255, + "type": "string" + }, + "serial-number": { + "maxLength": 16, + "type": "string", + "nullable": true + }, + "airtime-in-hours": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "last-serviced-at": { + "type": "string", + "format": "date-time", + "nullable": true + } + }, + "additionalProperties": false + }, + "airplane-attributes-in-response": { + "type": "object", + "properties": { + "name": { + "maxLength": 255, + "type": "string" + }, + "serial-number": { + "maxLength": 16, + "type": "string", + "nullable": true + }, + "airtime-in-hours": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "last-serviced-at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "manufactured-at": { + "type": "string", + "format": "date-time" + }, + "is-in-maintenance": { + "type": "boolean" + }, + "manufactured-in-city": { + "maxLength": 85, + "type": "string", + "nullable": true + }, + "kind": { + "$ref": "#/components/schemas/aircraft-kind" + } + }, + "additionalProperties": false + }, + "airplane-collection-response-document": { + "required": [ + "data", + "links" + ], + "type": "object", + "properties": { + "meta": { + "type": "object", + "additionalProperties": {} + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapi-object" + }, + "links": { + "$ref": "#/components/schemas/links-in-resource-collection-document" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/airplane-data-in-response" + } + } + }, + "additionalProperties": false + }, + "airplane-data-in-patch-request": { + "required": [ + "id", + "type" + ], + "type": "object", + "properties": { + "type": { + "$ref": "#/components/schemas/airplanes-resource-type" + }, + "id": { + "type": "string" + }, + "attributes": { + "$ref": "#/components/schemas/airplane-attributes-in-patch-request" + }, + "relationships": { + "$ref": "#/components/schemas/airplane-relationships-in-patch-request" + } + }, + "additionalProperties": false + }, + "airplane-data-in-post-request": { + "required": [ + "type" + ], + "type": "object", + "properties": { + "type": { + "$ref": "#/components/schemas/airplanes-resource-type" + }, + "attributes": { + "$ref": "#/components/schemas/airplane-attributes-in-post-request" + }, + "relationships": { + "$ref": "#/components/schemas/airplane-relationships-in-post-request" + } + }, + "additionalProperties": false + }, + "airplane-data-in-response": { + "required": [ + "id", + "links", + "type" + ], + "type": "object", + "properties": { + "type": { + "$ref": "#/components/schemas/airplanes-resource-type" + }, + "id": { + "type": "string" + }, + "attributes": { + "$ref": "#/components/schemas/airplane-attributes-in-response" + }, + "relationships": { + "$ref": "#/components/schemas/airplane-relationships-in-response" + }, + "links": { + "$ref": "#/components/schemas/links-in-resource-object" + }, + "meta": { + "type": "object", + "additionalProperties": {} + } + }, + "additionalProperties": false + }, + "airplane-patch-request-document": { + "required": [ + "data" + ], + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/airplane-data-in-patch-request" + } + }, + "additionalProperties": false + }, + "airplane-post-request-document": { + "required": [ + "data" + ], + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/airplane-data-in-post-request" + } + }, + "additionalProperties": false + }, + "airplane-primary-response-document": { + "required": [ + "data", + "links" + ], + "type": "object", + "properties": { + "meta": { + "type": "object", + "additionalProperties": {} + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapi-object" + }, + "links": { + "$ref": "#/components/schemas/links-in-resource-document" + }, + "data": { + "$ref": "#/components/schemas/airplane-data-in-response" + } + }, + "additionalProperties": false + }, + "airplane-relationships-in-patch-request": { + "type": "object", + "properties": { + "flights": { + "$ref": "#/components/schemas/to-many-flight-request-data" + } + }, + "additionalProperties": false + }, + "airplane-relationships-in-post-request": { + "type": "object", + "properties": { + "flights": { + "$ref": "#/components/schemas/to-many-flight-request-data" + } + }, + "additionalProperties": false + }, + "airplane-relationships-in-response": { + "type": "object", + "properties": { + "flights": { + "$ref": "#/components/schemas/to-many-flight-response-data" + } + }, + "additionalProperties": false + }, + "airplanes-resource-type": { + "enum": [ + "airplanes" + ], + "type": "string" + }, + "cabin-area": { + "enum": [ + "FirstClass", + "BusinessClass", + "EconomyClass" + ], + "type": "string" + }, + "flight-attendant-attributes-in-patch-request": { + "type": "object", + "properties": { + "email-address": { + "type": "string", + "format": "email" + }, + "age": { + "maximum": 75, + "minimum": 18, + "type": "integer", + "format": "int32" + } + }, + "additionalProperties": false + }, + "flight-attendant-attributes-in-post-request": { + "required": [ + "email-address", + "age", + "profile-image-url" + ], + "type": "object", + "properties": { + "email-address": { + "type": "string", + "format": "email" + }, + "age": { + "maximum": 75, + "minimum": 18, + "type": "integer", + "format": "int32" + }, + "profile-image-url": { + "type": "string", + "format": "uri" + } + }, + "additionalProperties": false + }, + "flight-attendant-attributes-in-response": { + "type": "object", + "properties": { + "email-address": { + "type": "string", + "format": "email" + }, + "age": { + "maximum": 75, + "minimum": 18, + "type": "integer", + "format": "int32" + }, + "profile-image-url": { + "type": "string", + "format": "uri" + }, + "distance-traveled-in-kilometers": { + "type": "integer", + "format": "int64" + } + }, + "additionalProperties": false + }, + "flight-attendant-collection-response-document": { + "required": [ + "data", + "links" + ], + "type": "object", + "properties": { + "meta": { + "type": "object", + "additionalProperties": {} + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapi-object" + }, + "links": { + "$ref": "#/components/schemas/links-in-resource-collection-document" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/flight-attendant-data-in-response" + } + } + }, + "additionalProperties": false + }, + "flight-attendant-data-in-patch-request": { + "required": [ + "id", + "type" + ], + "type": "object", + "properties": { + "type": { + "$ref": "#/components/schemas/flight-attendants-resource-type" + }, + "id": { + "type": "string" + }, + "attributes": { + "$ref": "#/components/schemas/flight-attendant-attributes-in-patch-request" + }, + "relationships": { + "$ref": "#/components/schemas/flight-attendant-relationships-in-patch-request" + } + }, + "additionalProperties": false + }, + "flight-attendant-data-in-post-request": { + "required": [ + "type" + ], + "type": "object", + "properties": { + "type": { + "$ref": "#/components/schemas/flight-attendants-resource-type" + }, + "attributes": { + "$ref": "#/components/schemas/flight-attendant-attributes-in-post-request" + }, + "relationships": { + "$ref": "#/components/schemas/flight-attendant-relationships-in-post-request" + } + }, + "additionalProperties": false + }, + "flight-attendant-data-in-response": { + "required": [ + "id", + "links", + "type" + ], + "type": "object", + "properties": { + "type": { + "$ref": "#/components/schemas/flight-attendants-resource-type" + }, + "id": { + "type": "string" + }, + "attributes": { + "$ref": "#/components/schemas/flight-attendant-attributes-in-response" + }, + "relationships": { + "$ref": "#/components/schemas/flight-attendant-relationships-in-response" + }, + "links": { + "$ref": "#/components/schemas/links-in-resource-object" + }, + "meta": { + "type": "object", + "additionalProperties": {} + } + }, + "additionalProperties": false + }, + "flight-attendant-identifier": { + "required": [ + "id", + "type" + ], + "type": "object", + "properties": { + "type": { + "$ref": "#/components/schemas/flight-attendants-resource-type" + }, + "id": { + "type": "string" + } + }, + "additionalProperties": false + }, + "flight-attendant-identifier-collection-response-document": { + "required": [ + "data", + "links" + ], + "type": "object", + "properties": { + "meta": { + "type": "object", + "additionalProperties": {} + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapi-object" + }, + "links": { + "$ref": "#/components/schemas/links-in-resource-identifier-collection-document" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/flight-attendant-identifier" + } + } + }, + "additionalProperties": false + }, + "flight-attendant-identifier-response-document": { + "required": [ + "data", + "links" + ], + "type": "object", + "properties": { + "meta": { + "type": "object", + "additionalProperties": {} + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapi-object" + }, + "links": { + "$ref": "#/components/schemas/links-in-resource-identifier-document" + }, + "data": { + "oneOf": [ + { + "$ref": "#/components/schemas/flight-attendant-identifier" + }, + { + "$ref": "#/components/schemas/null-value" + } + ] + } + }, + "additionalProperties": false + }, + "flight-attendant-patch-request-document": { + "required": [ + "data" + ], + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/flight-attendant-data-in-patch-request" + } + }, + "additionalProperties": false + }, + "flight-attendant-post-request-document": { + "required": [ + "data" + ], + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/flight-attendant-data-in-post-request" + } + }, + "additionalProperties": false + }, + "flight-attendant-primary-response-document": { + "required": [ + "data", + "links" + ], + "type": "object", + "properties": { + "meta": { + "type": "object", + "additionalProperties": {} + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapi-object" + }, + "links": { + "$ref": "#/components/schemas/links-in-resource-document" + }, + "data": { + "$ref": "#/components/schemas/flight-attendant-data-in-response" + } + }, + "additionalProperties": false + }, + "flight-attendant-relationships-in-patch-request": { + "type": "object", + "properties": { + "scheduled-for-flights": { + "$ref": "#/components/schemas/to-many-flight-request-data" + }, + "purser-on-flights": { + "$ref": "#/components/schemas/to-many-flight-request-data" + } + }, + "additionalProperties": false + }, + "flight-attendant-relationships-in-post-request": { + "type": "object", + "properties": { + "scheduled-for-flights": { + "$ref": "#/components/schemas/to-many-flight-request-data" + }, + "purser-on-flights": { + "$ref": "#/components/schemas/to-many-flight-request-data" + } + }, + "additionalProperties": false + }, + "flight-attendant-relationships-in-response": { + "type": "object", + "properties": { + "scheduled-for-flights": { + "$ref": "#/components/schemas/to-many-flight-response-data" + }, + "purser-on-flights": { + "$ref": "#/components/schemas/to-many-flight-response-data" + } + }, + "additionalProperties": false + }, + "flight-attendant-secondary-response-document": { + "required": [ + "data", + "links" + ], + "type": "object", + "properties": { + "meta": { + "type": "object", + "additionalProperties": {} + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapi-object" + }, + "links": { + "$ref": "#/components/schemas/links-in-resource-document" + }, + "data": { + "oneOf": [ + { + "$ref": "#/components/schemas/flight-attendant-data-in-response" + }, + { + "$ref": "#/components/schemas/null-value" + } + ] + } + }, + "additionalProperties": false + }, + "flight-attendants-resource-type": { + "enum": [ + "flight-attendants" + ], + "type": "string" + }, + "flight-attributes-in-patch-request": { + "type": "object", + "properties": { + "final-destination": { + "maxLength": 40, + "type": "string" + }, + "stop-over-destination": { + "maxLength": 2000, + "type": "string", + "nullable": true + }, + "operated-by": { + "$ref": "#/components/schemas/airline" + } + }, + "additionalProperties": false + }, + "flight-attributes-in-response": { + "type": "object", + "properties": { + "final-destination": { + "maxLength": 40, + "type": "string" + }, + "stop-over-destination": { + "maxLength": 2000, + "type": "string", + "nullable": true + }, + "operated-by": { + "$ref": "#/components/schemas/airline" + }, + "departs-at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "arrives-at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "services-on-board": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + } + }, + "additionalProperties": false + }, + "flight-collection-response-document": { + "required": [ + "data", + "links" + ], + "type": "object", + "properties": { + "meta": { + "type": "object", + "additionalProperties": {} + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapi-object" + }, + "links": { + "$ref": "#/components/schemas/links-in-resource-collection-document" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/flight-data-in-response" + } + } + }, + "additionalProperties": false + }, + "flight-data-in-patch-request": { + "required": [ + "id", + "type" + ], + "type": "object", + "properties": { + "type": { + "$ref": "#/components/schemas/flights-resource-type" + }, + "id": { + "type": "string" + }, + "attributes": { + "$ref": "#/components/schemas/flight-attributes-in-patch-request" + }, + "relationships": { + "$ref": "#/components/schemas/flight-relationships-in-patch-request" + } + }, + "additionalProperties": false + }, + "flight-data-in-post-request": { + "required": [ + "type" + ], + "type": "object", + "properties": { + "type": { + "$ref": "#/components/schemas/flights-resource-type" + }, + "relationships": { + "$ref": "#/components/schemas/flight-relationships-in-post-request" + } + }, + "additionalProperties": false + }, + "flight-data-in-response": { + "required": [ + "id", + "links", + "type" + ], + "type": "object", + "properties": { + "type": { + "$ref": "#/components/schemas/flights-resource-type" + }, + "id": { + "type": "string" + }, + "attributes": { + "$ref": "#/components/schemas/flight-attributes-in-response" + }, + "relationships": { + "$ref": "#/components/schemas/flight-relationships-in-response" + }, + "links": { + "$ref": "#/components/schemas/links-in-resource-object" + }, + "meta": { + "type": "object", + "additionalProperties": {} + } + }, + "additionalProperties": false + }, + "flight-identifier": { + "required": [ + "id", + "type" + ], + "type": "object", + "properties": { + "type": { + "$ref": "#/components/schemas/flights-resource-type" + }, + "id": { + "type": "string" + } + }, + "additionalProperties": false + }, + "flight-identifier-collection-response-document": { + "required": [ + "data", + "links" + ], + "type": "object", + "properties": { + "meta": { + "type": "object", + "additionalProperties": {} + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapi-object" + }, + "links": { + "$ref": "#/components/schemas/links-in-resource-identifier-collection-document" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/flight-identifier" + } + } + }, + "additionalProperties": false + }, + "flight-patch-request-document": { + "required": [ + "data" + ], + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/flight-data-in-patch-request" + } + }, + "additionalProperties": false + }, + "flight-post-request-document": { + "required": [ + "data" + ], + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/flight-data-in-post-request" + } + }, + "additionalProperties": false + }, + "flight-primary-response-document": { + "required": [ + "data", + "links" + ], + "type": "object", + "properties": { + "meta": { + "type": "object", + "additionalProperties": {} + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapi-object" + }, + "links": { + "$ref": "#/components/schemas/links-in-resource-document" + }, + "data": { + "$ref": "#/components/schemas/flight-data-in-response" + } + }, + "additionalProperties": false + }, + "flight-relationships-in-patch-request": { + "type": "object", + "properties": { + "cabin-crew-members": { + "$ref": "#/components/schemas/to-many-flight-attendant-request-data" + }, + "purser": { + "$ref": "#/components/schemas/to-one-flight-attendant-request-data" + }, + "passengers": { + "$ref": "#/components/schemas/to-many-passenger-request-data" + } + }, + "additionalProperties": false + }, + "flight-relationships-in-post-request": { + "type": "object", + "properties": { + "cabin-crew-members": { + "$ref": "#/components/schemas/to-many-flight-attendant-request-data" + }, + "purser": { + "$ref": "#/components/schemas/to-one-flight-attendant-request-data" + }, + "passengers": { + "$ref": "#/components/schemas/to-many-passenger-request-data" + } + }, + "additionalProperties": false + }, + "flight-relationships-in-response": { + "type": "object", + "properties": { + "cabin-crew-members": { + "$ref": "#/components/schemas/to-many-flight-attendant-response-data" + }, + "purser": { + "$ref": "#/components/schemas/to-one-flight-attendant-response-data" + }, + "passengers": { + "$ref": "#/components/schemas/to-many-passenger-response-data" + } + }, + "additionalProperties": false + }, + "flights-resource-type": { + "enum": [ + "flights" + ], + "type": "string" + }, + "jsonapi-object": { + "type": "object", + "properties": { + "version": { + "type": "string" + }, + "ext": { + "type": "array", + "items": { + "type": "string" + } + }, + "profile": { + "type": "array", + "items": { + "type": "string" + } + }, + "meta": { + "type": "object", + "additionalProperties": {} + } + }, + "additionalProperties": false + }, + "links-in-relationship-object": { + "required": [ + "related", + "self" + ], + "type": "object", + "properties": { + "self": { + "type": "string" + }, + "related": { + "type": "string" + } + }, + "additionalProperties": false + }, + "links-in-resource-collection-document": { + "required": [ + "first", + "self" + ], + "type": "object", + "properties": { + "self": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "first": { + "type": "string" + }, + "last": { + "type": "string" + }, + "prev": { + "type": "string" + }, + "next": { + "type": "string" + } + }, + "additionalProperties": false + }, + "links-in-resource-document": { + "required": [ + "self" + ], + "type": "object", + "properties": { + "self": { + "type": "string" + }, + "describedby": { + "type": "string" + } + }, + "additionalProperties": false + }, + "links-in-resource-identifier-collection-document": { + "required": [ + "first", + "related", + "self" + ], + "type": "object", + "properties": { + "self": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "related": { + "type": "string" + }, + "first": { + "type": "string" + }, + "last": { + "type": "string" + }, + "prev": { + "type": "string" + }, + "next": { + "type": "string" + } + }, + "additionalProperties": false + }, + "links-in-resource-identifier-document": { + "required": [ + "related", + "self" + ], + "type": "object", + "properties": { + "self": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "related": { + "type": "string" + } + }, + "additionalProperties": false + }, + "links-in-resource-object": { + "required": [ + "self" + ], + "type": "object", + "properties": { + "self": { + "type": "string" + } + }, + "additionalProperties": false + }, + "null-value": { + "not": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "object" + }, + { + "type": "array" + } + ], + "items": {} + }, + "nullable": true + }, + "passenger-attributes-in-response": { + "type": "object", + "properties": { + "full-name": { + "type": "string", + "nullable": true + }, + "cabin-area": { + "$ref": "#/components/schemas/cabin-area" + } + }, + "additionalProperties": false + }, + "passenger-collection-response-document": { + "required": [ + "data", + "links" + ], + "type": "object", + "properties": { + "meta": { + "type": "object", + "additionalProperties": {} + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapi-object" + }, + "links": { + "$ref": "#/components/schemas/links-in-resource-collection-document" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/passenger-data-in-response" + } + } + }, + "additionalProperties": false + }, + "passenger-data-in-response": { + "required": [ + "id", + "links", + "type" + ], + "type": "object", + "properties": { + "type": { + "$ref": "#/components/schemas/passengers-resource-type" + }, + "id": { + "type": "string" + }, + "attributes": { + "$ref": "#/components/schemas/passenger-attributes-in-response" + }, + "links": { + "$ref": "#/components/schemas/links-in-resource-object" + }, + "meta": { + "type": "object", + "additionalProperties": {} + } + }, + "additionalProperties": false + }, + "passenger-identifier": { + "required": [ + "id", + "type" + ], + "type": "object", + "properties": { + "type": { + "$ref": "#/components/schemas/passengers-resource-type" + }, + "id": { + "type": "string" + } + }, + "additionalProperties": false + }, + "passenger-identifier-collection-response-document": { + "required": [ + "data", + "links" + ], + "type": "object", + "properties": { + "meta": { + "type": "object", + "additionalProperties": {} + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapi-object" + }, + "links": { + "$ref": "#/components/schemas/links-in-resource-identifier-collection-document" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/passenger-identifier" + } + } + }, + "additionalProperties": false + }, + "passengers-resource-type": { + "enum": [ + "passengers" + ], + "type": "string" + }, + "to-many-flight-attendant-request-data": { + "required": [ + "data" + ], + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/flight-attendant-identifier" + } + } + }, + "additionalProperties": false + }, + "to-many-flight-attendant-response-data": { + "required": [ + "links" + ], + "type": "object", + "properties": { + "links": { + "$ref": "#/components/schemas/links-in-relationship-object" + }, + "meta": { + "type": "object", + "additionalProperties": {} + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/flight-attendant-identifier" + } + } + }, + "additionalProperties": false + }, + "to-many-flight-request-data": { + "required": [ + "data" + ], + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/flight-identifier" + } + } + }, + "additionalProperties": false + }, + "to-many-flight-response-data": { + "required": [ + "links" + ], + "type": "object", + "properties": { + "links": { + "$ref": "#/components/schemas/links-in-relationship-object" + }, + "meta": { + "type": "object", + "additionalProperties": {} + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/flight-identifier" + } + } + }, + "additionalProperties": false + }, + "to-many-passenger-request-data": { + "required": [ + "data" + ], + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/passenger-identifier" + } + } + }, + "additionalProperties": false + }, + "to-many-passenger-response-data": { + "required": [ + "links" + ], + "type": "object", + "properties": { + "links": { + "$ref": "#/components/schemas/links-in-relationship-object" + }, + "meta": { + "type": "object", + "additionalProperties": {} + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/passenger-identifier" + } + } + }, + "additionalProperties": false + }, + "to-one-flight-attendant-request-data": { + "required": [ + "data" + ], + "type": "object", + "properties": { + "data": { + "oneOf": [ + { + "$ref": "#/components/schemas/flight-attendant-identifier" + }, + { + "$ref": "#/components/schemas/null-value" + } + ] + } + }, + "additionalProperties": false + }, + "to-one-flight-attendant-response-data": { + "required": [ + "links" + ], + "type": "object", + "properties": { + "links": { + "$ref": "#/components/schemas/links-in-relationship-object" + }, + "meta": { + "type": "object", + "additionalProperties": {} + }, + "data": { + "oneOf": [ + { + "$ref": "#/components/schemas/flight-attendant-identifier" + }, + { + "$ref": "#/components/schemas/null-value" + } + ] + } + }, + "additionalProperties": false + } + } + } +} diff --git a/test/OpenApiTests/OpenApiDbContext.cs b/test/OpenApiTests/OpenApiDbContext.cs deleted file mode 100644 index 77e99aaddc..0000000000 --- a/test/OpenApiTests/OpenApiDbContext.cs +++ /dev/null @@ -1,17 +0,0 @@ -using JetBrains.Annotations; -using Microsoft.EntityFrameworkCore; - -namespace OpenApiTests -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class OpenApiDbContext : DbContext - { - public DbSet Airplanes { get; set; } - public DbSet Flights { get; set; } - - public OpenApiDbContext(DbContextOptions options) - : base(options) - { - } - } -} diff --git a/test/OpenApiTests/OpenApiStartup.cs b/test/OpenApiTests/OpenApiStartup.cs index 88f956a0f6..992ea57218 100644 --- a/test/OpenApiTests/OpenApiStartup.cs +++ b/test/OpenApiTests/OpenApiStartup.cs @@ -9,7 +9,7 @@ namespace OpenApiTests { - public sealed class OpenApiStartup : TestableStartup + public abstract class OpenApiStartup : TestableStartup where TDbContext : DbContext { public override void ConfigureServices(IServiceCollection services) diff --git a/test/OpenApiTests/OpenApiTests.csproj b/test/OpenApiTests/OpenApiTests.csproj index c4d01aadcf..04355533a7 100644 --- a/test/OpenApiTests/OpenApiTests.csproj +++ b/test/OpenApiTests/OpenApiTests.csproj @@ -4,6 +4,7 @@ + @@ -15,7 +16,7 @@ - + diff --git a/test/OpenApiTests/swagger.json b/test/OpenApiTests/swagger.json deleted file mode 100644 index e90093c24d..0000000000 --- a/test/OpenApiTests/swagger.json +++ /dev/null @@ -1,963 +0,0 @@ -{ - "openapi": "3.0.1", - "info": { - "title": "OpenApiTests", - "version": "1.0" - }, - "paths": { - "/airplanes": { - "get": { - "tags": [ - "Airplanes" - ], - "responses": { - "200": { - "description": "Success" - } - } - }, - "head": { - "tags": [ - "Airplanes" - ], - "responses": { - "200": { - "description": "Success" - } - } - }, - "post": { - "tags": [ - "Airplanes" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Airplane" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/Airplane" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/Airplane" - } - } - } - }, - "responses": { - "200": { - "description": "Success" - } - } - } - }, - "/airplanes/{id}": { - "get": { - "tags": [ - "Airplanes" - ], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "format": "int32" - } - } - ], - "responses": { - "200": { - "description": "Success" - } - } - }, - "head": { - "tags": [ - "Airplanes" - ], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "format": "int32" - } - } - ], - "responses": { - "200": { - "description": "Success" - } - } - }, - "patch": { - "tags": [ - "Airplanes" - ], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "format": "int32" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Airplane" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/Airplane" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/Airplane" - } - } - } - }, - "responses": { - "200": { - "description": "Success" - } - } - }, - "delete": { - "tags": [ - "Airplanes" - ], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "format": "int32" - } - } - ], - "responses": { - "200": { - "description": "Success" - } - } - } - }, - "/airplanes/{id}/{relationshipName}": { - "get": { - "tags": [ - "Airplanes" - ], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "relationshipName", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Success" - } - } - }, - "head": { - "tags": [ - "Airplanes" - ], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "relationshipName", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Success" - } - } - } - }, - "/airplanes/{id}/relationships/{relationshipName}": { - "get": { - "tags": [ - "Airplanes" - ], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "relationshipName", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Success" - } - } - }, - "head": { - "tags": [ - "Airplanes" - ], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "relationshipName", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Success" - } - } - }, - "post": { - "tags": [ - "Airplanes" - ], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "relationshipName", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "uniqueItems": true, - "type": "array", - "items": { - "$ref": "#/components/schemas/IIdentifiable" - } - } - }, - "text/json": { - "schema": { - "uniqueItems": true, - "type": "array", - "items": { - "$ref": "#/components/schemas/IIdentifiable" - } - } - }, - "application/*+json": { - "schema": { - "uniqueItems": true, - "type": "array", - "items": { - "$ref": "#/components/schemas/IIdentifiable" - } - } - } - } - }, - "responses": { - "200": { - "description": "Success" - } - } - }, - "patch": { - "tags": [ - "Airplanes" - ], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "relationshipName", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": {} - }, - "text/json": { - "schema": {} - }, - "application/*+json": { - "schema": {} - } - } - }, - "responses": { - "200": { - "description": "Success" - } - } - }, - "delete": { - "tags": [ - "Airplanes" - ], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "relationshipName", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "uniqueItems": true, - "type": "array", - "items": { - "$ref": "#/components/schemas/IIdentifiable" - } - } - }, - "text/json": { - "schema": { - "uniqueItems": true, - "type": "array", - "items": { - "$ref": "#/components/schemas/IIdentifiable" - } - } - }, - "application/*+json": { - "schema": { - "uniqueItems": true, - "type": "array", - "items": { - "$ref": "#/components/schemas/IIdentifiable" - } - } - } - } - }, - "responses": { - "200": { - "description": "Success" - } - } - } - }, - "/flights": { - "get": { - "tags": [ - "Flights" - ], - "responses": { - "200": { - "description": "Success" - } - } - }, - "head": { - "tags": [ - "Flights" - ], - "responses": { - "200": { - "description": "Success" - } - } - }, - "post": { - "tags": [ - "Flights" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Flight" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/Flight" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/Flight" - } - } - } - }, - "responses": { - "200": { - "description": "Success" - } - } - } - }, - "/flights/{id}": { - "get": { - "tags": [ - "Flights" - ], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "format": "int32" - } - } - ], - "responses": { - "200": { - "description": "Success" - } - } - }, - "head": { - "tags": [ - "Flights" - ], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "format": "int32" - } - } - ], - "responses": { - "200": { - "description": "Success" - } - } - }, - "patch": { - "tags": [ - "Flights" - ], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "format": "int32" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Flight" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/Flight" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/Flight" - } - } - } - }, - "responses": { - "200": { - "description": "Success" - } - } - }, - "delete": { - "tags": [ - "Flights" - ], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "format": "int32" - } - } - ], - "responses": { - "200": { - "description": "Success" - } - } - } - }, - "/flights/{id}/{relationshipName}": { - "get": { - "tags": [ - "Flights" - ], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "relationshipName", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Success" - } - } - }, - "head": { - "tags": [ - "Flights" - ], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "relationshipName", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Success" - } - } - } - }, - "/flights/{id}/relationships/{relationshipName}": { - "get": { - "tags": [ - "Flights" - ], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "relationshipName", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Success" - } - } - }, - "head": { - "tags": [ - "Flights" - ], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "relationshipName", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Success" - } - } - }, - "post": { - "tags": [ - "Flights" - ], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "relationshipName", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "uniqueItems": true, - "type": "array", - "items": { - "$ref": "#/components/schemas/IIdentifiable" - } - } - }, - "text/json": { - "schema": { - "uniqueItems": true, - "type": "array", - "items": { - "$ref": "#/components/schemas/IIdentifiable" - } - } - }, - "application/*+json": { - "schema": { - "uniqueItems": true, - "type": "array", - "items": { - "$ref": "#/components/schemas/IIdentifiable" - } - } - } - } - }, - "responses": { - "200": { - "description": "Success" - } - } - }, - "patch": { - "tags": [ - "Flights" - ], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "relationshipName", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": {} - }, - "text/json": { - "schema": {} - }, - "application/*+json": { - "schema": {} - } - } - }, - "responses": { - "200": { - "description": "Success" - } - } - }, - "delete": { - "tags": [ - "Flights" - ], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "relationshipName", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "uniqueItems": true, - "type": "array", - "items": { - "$ref": "#/components/schemas/IIdentifiable" - } - } - }, - "text/json": { - "schema": { - "uniqueItems": true, - "type": "array", - "items": { - "$ref": "#/components/schemas/IIdentifiable" - } - } - }, - "application/*+json": { - "schema": { - "uniqueItems": true, - "type": "array", - "items": { - "$ref": "#/components/schemas/IIdentifiable" - } - } - } - } - }, - "responses": { - "200": { - "description": "Success" - } - } - } - } - }, - "components": { - "schemas": { - "Airplane": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "format": "int32" - }, - "stringId": { - "type": "string", - "nullable": true - }, - "localId": { - "type": "string", - "nullable": true - }, - "seatingCapacity": { - "type": "integer", - "format": "int32" - }, - "manufacturedAt": { - "type": "string", - "format": "date-time" - }, - "flights": { - "uniqueItems": true, - "type": "array", - "items": { - "$ref": "#/components/schemas/Flight" - }, - "nullable": true - } - }, - "additionalProperties": false - }, - "Flight": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "format": "int32" - }, - "stringId": { - "type": "string", - "nullable": true - }, - "localId": { - "type": "string", - "nullable": true - }, - "destination": { - "type": "string", - "nullable": true - }, - "departsAt": { - "type": "string", - "format": "date-time" - } - }, - "additionalProperties": false - }, - "IIdentifiable": { - "type": "object", - "properties": { - "stringId": { - "type": "string", - "nullable": true - }, - "localId": { - "type": "string", - "nullable": true - } - }, - "additionalProperties": false - } - } - } -} diff --git a/test/TestBuildingBlocks/HttpRequestHeadersExtensions.cs b/test/TestBuildingBlocks/HttpRequestHeadersExtensions.cs new file mode 100644 index 0000000000..f8cd609c82 --- /dev/null +++ b/test/TestBuildingBlocks/HttpRequestHeadersExtensions.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net.Http.Headers; +using Microsoft.Extensions.Primitives; + +namespace TestBuildingBlocks +{ + public static class HttpRequestHeadersExtensions + { + /// + /// Returns the value of the specified HTTP request header, or null when not found. If the header occurs multiple times, their values are + /// collapsed into a comma-separated string, without changing any surrounding double quotes. + /// + public static string GetValue(this HttpRequestHeaders requestHeaders, string name) + { + bool headerExists = requestHeaders.TryGetValues(name, out IEnumerable values); + + return headerExists ? new StringValues(values.ToArray()).ToString() : null; + } + } +}