diff --git a/src/OpenApi/sample/Endpoints/MapSchemasEndpoints.cs b/src/OpenApi/sample/Endpoints/MapSchemasEndpoints.cs index 5aea239062db..205db25ecd4e 100644 --- a/src/OpenApi/sample/Endpoints/MapSchemasEndpoints.cs +++ b/src/OpenApi/sample/Endpoints/MapSchemasEndpoints.cs @@ -37,7 +37,8 @@ public static IEndpointRouteBuilder MapSchemasEndpoints(this IEndpointRouteBuild schemas.MapPost("/location", (LocationContainer location) => { }); schemas.MapPost("/parent", (ParentObject parent) => Results.Ok(parent)); schemas.MapPost("/child", (ChildObject child) => Results.Ok(child)); - schemas.MapPatch("/json-patch", (JsonPatchDocument patchDoc) => Results.NoContent()); + schemas.MapPatch("/json-patch", (JsonPatchDocument patchDoc) => Results.NoContent()); + schemas.MapPatch("/json-patch-generic", (JsonPatchDocument patchDoc) => Results.NoContent()); return endpointRouteBuilder; } diff --git a/src/OpenApi/src/Extensions/JsonTypeInfoExtensions.cs b/src/OpenApi/src/Extensions/JsonTypeInfoExtensions.cs index e09e2626444c..be025a61b529 100644 --- a/src/OpenApi/src/Extensions/JsonTypeInfoExtensions.cs +++ b/src/OpenApi/src/Extensions/JsonTypeInfoExtensions.cs @@ -66,6 +66,14 @@ internal static class JsonTypeInfoExtensions return simpleName; } + // Use the same JSON Patch schema for all JSON Patch document types (JsonPatchDocument, + // JsonPatchDocument, derived types, etc.) as otherwise we'll generate a schema + // per unique type which are otherwise identical to each other. + if (type.IsJsonPatchDocument()) + { + return "JsonPatchDocument"; + } + // Although arrays are enumerable types they are not encoded correctly // with JsonTypeInfoKind.Enumerable so we handle the Enumerable type // case here. diff --git a/src/OpenApi/src/Extensions/TypeExtensions.cs b/src/OpenApi/src/Extensions/TypeExtensions.cs new file mode 100644 index 000000000000..e2ba5f500c63 --- /dev/null +++ b/src/OpenApi/src/Extensions/TypeExtensions.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.OpenApi; + +internal static class TypeExtensions +{ + private const string JsonPatchDocumentNamespace = "Microsoft.AspNetCore.JsonPatch.SystemTextJson"; + private const string JsonPatchDocumentName = "JsonPatchDocument"; + private const string JsonPatchDocumentNameOfT = "JsonPatchDocument`1"; + + public static bool IsJsonPatchDocument(this Type type) + { + // We cannot depend on the actual runtime type as + // Microsoft.AspNetCore.JsonPatch.SystemTextJson is not + // AoT compatible so cannot be referenced by Microsoft.AspNetCore.OpenApi. + var modelType = type; + + while (modelType != null && modelType != typeof(object)) + { + if (modelType.Namespace == JsonPatchDocumentNamespace && + (modelType.Name == JsonPatchDocumentName || + (modelType.IsGenericType && modelType.GenericTypeArguments.Length == 1 && modelType.Name == JsonPatchDocumentNameOfT))) + { + return true; + } + + modelType = modelType.BaseType; + } + + return false; + } +} diff --git a/src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj b/src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj index 7bf5362ca205..66aa82bbf270 100644 --- a/src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj +++ b/src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj @@ -14,13 +14,13 @@ - + - - - + + + diff --git a/src/OpenApi/src/Schemas/OpenApiJsonSchema.Helpers.cs b/src/OpenApi/src/Schemas/OpenApiJsonSchema.Helpers.cs index 3b0937db091e..0b660ceb1d66 100644 --- a/src/OpenApi/src/Schemas/OpenApiJsonSchema.Helpers.cs +++ b/src/OpenApi/src/Schemas/OpenApiJsonSchema.Helpers.cs @@ -298,8 +298,14 @@ public static void ReadProperty(ref Utf8JsonReader reader, string propertyName, case OpenApiSchemaKeywords.AnyOfKeyword: reader.Read(); schema.Type = JsonSchemaType.Object; - var schemas = ReadList(ref reader); - schema.AnyOf = schemas?.Select(s => s.Schema as IOpenApiSchema).ToList(); + var anyOfSchemas = ReadList(ref reader); + schema.AnyOf = anyOfSchemas?.Select(s => s.Schema as IOpenApiSchema).ToList(); + break; + case OpenApiSchemaKeywords.OneOfKeyword: + reader.Read(); + schema.Type = JsonSchemaType.Object; + var oneOfSchemas = ReadList(ref reader); + schema.OneOf = oneOfSchemas?.Select(s => s.Schema as IOpenApiSchema).ToList(); break; case OpenApiSchemaKeywords.DiscriminatorKeyword: reader.Read(); diff --git a/src/OpenApi/src/Schemas/OpenApiSchemaKeywords.cs b/src/OpenApi/src/Schemas/OpenApiSchemaKeywords.cs index 84f27500d135..104b80798c96 100644 --- a/src/OpenApi/src/Schemas/OpenApiSchemaKeywords.cs +++ b/src/OpenApi/src/Schemas/OpenApiSchemaKeywords.cs @@ -10,6 +10,7 @@ internal class OpenApiSchemaKeywords public const string AdditionalPropertiesKeyword = "additionalProperties"; public const string RequiredKeyword = "required"; public const string AnyOfKeyword = "anyOf"; + public const string OneOfKeyword = "oneOf"; public const string EnumKeyword = "enum"; public const string DefaultKeyword = "default"; public const string DescriptionKeyword = "description"; diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs index 8e42ac8e11a2..5308dc3792a1 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentService.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs @@ -720,6 +720,12 @@ private async Task GetJsonRequestBody( // for stream-based parameter types. supportedRequestFormats = [new ApiRequestFormat { MediaType = "application/octet-stream" }]; } + else if (bodyParameter.Type.IsJsonPatchDocument()) + { + // Assume "application/json-patch+json" as the default media type + // for JSON Patch documents. + supportedRequestFormats = [new ApiRequestFormat { MediaType = "application/json-patch+json" }]; + } else { // Assume "application/json" as the default media type diff --git a/src/OpenApi/src/Services/OpenApiGenerator.cs b/src/OpenApi/src/Services/OpenApiGenerator.cs index 8ed8d075062e..6257f7fa2fea 100644 --- a/src/OpenApi/src/Services/OpenApiGenerator.cs +++ b/src/OpenApi/src/Services/OpenApiGenerator.cs @@ -447,7 +447,9 @@ private List GetOpenApiParameters(MethodInfo methodInfo, Rout return (false, ParameterLocation.Query, null); } } - else if (parameter.ParameterType == typeof(IFormFile) || parameter.ParameterType == typeof(IFormFileCollection)) + else if (parameter.ParameterType == typeof(IFormFile) || + parameter.ParameterType == typeof(IFormFileCollection) || + parameter.ParameterType.IsJsonPatchDocument()) { return (true, null, null); } diff --git a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs index 10737cbd5f20..2971ab2e9b6c 100644 --- a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs +++ b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs @@ -82,6 +82,10 @@ internal sealed class OpenApiSchemaService( } }; } + else if (type.IsJsonPatchDocument()) + { + schema = CreateSchemaForJsonPatch(); + } // STJ uses `true` in place of an empty object to represent a schema that matches // anything (like the `object` type) or types with user-defined converters. We override // this default behavior here to match the format expected in OpenAPI v3. @@ -117,6 +121,96 @@ internal sealed class OpenApiSchemaService( } }; + private static JsonObject CreateSchemaForJsonPatch() + { + var addReplaceTest = new JsonObject() + { + [OpenApiSchemaKeywords.TypeKeyword] = "object", + [OpenApiSchemaKeywords.AdditionalPropertiesKeyword] = false, + [OpenApiSchemaKeywords.RequiredKeyword] = JsonArray(["op", "path", "value"]), + [OpenApiSchemaKeywords.PropertiesKeyword] = new JsonObject + { + ["op"] = new JsonObject() + { + [OpenApiSchemaKeywords.TypeKeyword] = "string", + [OpenApiSchemaKeywords.EnumKeyword] = JsonArray(["add", "replace", "test"]), + }, + ["path"] = new JsonObject() + { + [OpenApiSchemaKeywords.TypeKeyword] = "string" + }, + ["value"] = new JsonObject() + } + }; + + var moveCopy = new JsonObject() + { + [OpenApiSchemaKeywords.TypeKeyword] = "object", + [OpenApiSchemaKeywords.AdditionalPropertiesKeyword] = false, + [OpenApiSchemaKeywords.RequiredKeyword] = JsonArray(["op", "path", "from"]), + [OpenApiSchemaKeywords.PropertiesKeyword] = new JsonObject + { + ["op"] = new JsonObject() + { + [OpenApiSchemaKeywords.TypeKeyword] = "string", + [OpenApiSchemaKeywords.EnumKeyword] = JsonArray(["move", "copy"]), + }, + ["path"] = new JsonObject() + { + [OpenApiSchemaKeywords.TypeKeyword] = "string" + }, + ["from"] = new JsonObject() + { + [OpenApiSchemaKeywords.TypeKeyword] = "string" + }, + } + }; + + var remove = new JsonObject() + { + [OpenApiSchemaKeywords.TypeKeyword] = "object", + [OpenApiSchemaKeywords.AdditionalPropertiesKeyword] = false, + [OpenApiSchemaKeywords.RequiredKeyword] = JsonArray(["op", "path"]), + [OpenApiSchemaKeywords.PropertiesKeyword] = new JsonObject + { + ["op"] = new JsonObject() + { + [OpenApiSchemaKeywords.TypeKeyword] = "string", + [OpenApiSchemaKeywords.EnumKeyword] = JsonArray(["remove"]) + }, + ["path"] = new JsonObject() + { + [OpenApiSchemaKeywords.TypeKeyword] = "string" + }, + } + }; + + return new JsonObject + { + [OpenApiConstants.SchemaId] = "JsonPatchDocument", + [OpenApiSchemaKeywords.TypeKeyword] = "array", + [OpenApiSchemaKeywords.ItemsKeyword] = new JsonObject + { + [OpenApiSchemaKeywords.OneOfKeyword] = JsonArray([addReplaceTest, moveCopy, remove]) + }, + }; + + // Using JsonArray inline causes the compile to pick the generic Add() overload + // which then generates native AoT warnings without adding a cost. To Avoid that use + // this helper method that uses JsonNode to pick the native AoT compatible overload instead. + static JsonArray JsonArray(ReadOnlySpan values) + { + var array = new JsonArray(); + + foreach (var value in values) + { + array.Add(value); + } + + return array; + } + } + internal async Task GetOrCreateUnresolvedSchemaAsync(OpenApiDocument? document, Type type, IServiceProvider scopedServiceProvider, IOpenApiSchemaTransformer[] schemaTransformers, ApiParameterDescription? parameterDescription = null, CancellationToken cancellationToken = default) { var key = parameterDescription?.ParameterDescriptor is IParameterInfoParameterDescriptor parameterInfoDescription @@ -320,9 +414,10 @@ private async Task InnerApplySchemaTransformersAsync(IOpenApiSchema inputSchema, } } - if (schema.Items is not null) + // If the schema is an array but uses AnyOf or OneOf then ElementType is null + if (schema.Items is not null && jsonTypeInfo.ElementType is not null) { - var elementTypeInfo = _jsonSerializerOptions.GetTypeInfo(jsonTypeInfo.ElementType!); + var elementTypeInfo = _jsonSerializerOptions.GetTypeInfo(jsonTypeInfo.ElementType); await InnerApplySchemaTransformersAsync(schema.Items, elementTypeInfo, null, context, transformer, cancellationToken); } diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Extensions/JsonTypeInfoExtensionsTests.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Extensions/JsonTypeInfoExtensionsTests.cs index 36ebce8312e3..a402ef393a59 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Extensions/JsonTypeInfoExtensionsTests.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Extensions/JsonTypeInfoExtensionsTests.cs @@ -5,6 +5,7 @@ using System.Text.Json; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.OpenApi; @@ -58,6 +59,8 @@ public class Baz [(new { Id = 1, Name = "Todo" }).GetType(), "AnonymousTypeOfintAndstring"], [typeof(IFormFile), "IFormFile"], [typeof(IFormFileCollection), "IFormFileCollection"], + [typeof(JsonPatchDocument), "JsonPatchDocument"], + [typeof(JsonPatchDocument), "JsonPatchDocument"], [typeof(Stream), "Stream"], [typeof(PipeReader), "PipeReader"], [typeof(Results, Ok>), "ResultsOfOkOfTodoWithDueDateAndOkOfTodo"], diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt index 0eecf7e47ee5..9923cc74c7cd 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt @@ -517,7 +517,29 @@ "content": { "application/json-patch+json": { "schema": { - "$ref": "#/components/schemas/JsonPatchDocumentOfParentObject" + "$ref": "#/components/schemas/JsonPatchDocument" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/json-patch-generic": { + "patch": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/JsonPatchDocument" } } }, @@ -621,7 +643,80 @@ } } }, - "JsonPatchDocumentOfParentObject": { }, + "JsonPatchDocument": { + "type": "array", + "items": { + "type": "object", + "oneOf": [ + { + "required": [ + "op", + "path", + "value" + ], + "type": "object", + "properties": { + "op": { + "enum": [ + "add", + "replace", + "test" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "value": { } + }, + "additionalProperties": false + }, + { + "required": [ + "op", + "path", + "from" + ], + "type": "object", + "properties": { + "op": { + "enum": [ + "move", + "copy" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "from": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "required": [ + "op", + "path" + ], + "type": "object", + "properties": { + "op": { + "enum": [ + "remove" + ], + "type": "string" + }, + "path": { + "type": "string" + } + }, + "additionalProperties": false + } + ] + } + }, "LocationContainer": { "required": [ "location" diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt index 166b57e6aaab..3bff469970a1 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt @@ -517,7 +517,29 @@ "content": { "application/json-patch+json": { "schema": { - "$ref": "#/components/schemas/JsonPatchDocumentOfParentObject" + "$ref": "#/components/schemas/JsonPatchDocument" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/json-patch-generic": { + "patch": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/JsonPatchDocument" } } }, @@ -621,7 +643,80 @@ } } }, - "JsonPatchDocumentOfParentObject": { }, + "JsonPatchDocument": { + "type": "array", + "items": { + "type": "object", + "oneOf": [ + { + "required": [ + "op", + "path", + "value" + ], + "type": "object", + "properties": { + "op": { + "enum": [ + "add", + "replace", + "test" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "value": { } + }, + "additionalProperties": false + }, + { + "required": [ + "op", + "path", + "from" + ], + "type": "object", + "properties": { + "op": { + "enum": [ + "move", + "copy" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "from": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "required": [ + "op", + "path" + ], + "type": "object", + "properties": { + "op": { + "enum": [ + "remove" + ], + "type": "string" + }, + "path": { + "type": "string" + } + }, + "additionalProperties": false + } + ] + } + }, "LocationContainer": { "required": [ "location" diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.verified.txt index 3b8db723fdee..f7870bc2fe95 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.verified.txt @@ -1042,7 +1042,29 @@ "content": { "application/json-patch+json": { "schema": { - "$ref": "#/components/schemas/JsonPatchDocumentOfParentObject" + "$ref": "#/components/schemas/JsonPatchDocument" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/json-patch-generic": { + "patch": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/JsonPatchDocument" } } }, @@ -1410,7 +1432,80 @@ } } }, - "JsonPatchDocumentOfParentObject": { }, + "JsonPatchDocument": { + "type": "array", + "items": { + "type": "object", + "oneOf": [ + { + "required": [ + "op", + "path", + "value" + ], + "type": "object", + "properties": { + "op": { + "enum": [ + "add", + "replace", + "test" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "value": { } + }, + "additionalProperties": false + }, + { + "required": [ + "op", + "path", + "from" + ], + "type": "object", + "properties": { + "op": { + "enum": [ + "move", + "copy" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "from": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "required": [ + "op", + "path" + ], + "type": "object", + "properties": { + "op": { + "enum": [ + "remove" + ], + "type": "string" + }, + "path": { + "type": "string" + } + }, + "additionalProperties": false + } + ] + } + }, "LocationContainer": { "required": [ "location" diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.RequestBody.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.RequestBody.cs index 5601f9b1d12d..8a38a89952f4 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.RequestBody.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.RequestBody.cs @@ -3,9 +3,11 @@ using System.IO.Pipelines; using System.Net.Http; +using System.Text.Json.Serialization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.InternalTesting; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson; using Microsoft.AspNetCore.Mvc; public partial class OpenApiDocumentServiceTests : OpenApiDocumentServiceTestBase @@ -1131,4 +1133,318 @@ static void VerifyDocument(OpenApiDocument document) private void ActionWithStream(Stream stream) { } [Route("/pipereader")] private void ActionWithPipeReader(PipeReader pipeReader) { } + + [Fact] + public async Task GetRequestBody_HandlesJsonPatchBody() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPatch("/", (JsonPatchDocument patch) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var paths = Assert.Single(document.Paths.Values); + var operation = paths.Operations[HttpMethod.Patch]; + Assert.NotNull(operation.RequestBody); + Assert.False(operation.RequestBody.Required); + Assert.NotNull(operation.RequestBody.Content); + var content = Assert.Single(operation.RequestBody.Content); + Assert.Equal("application/json-patch+json", content.Key); + var schema = Assert.IsType(content.Value.Schema); + Assert.Equal("JsonPatchDocument", schema.Reference.Id); + }); + } + +#nullable enable + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task GetRequestBody_HandlesJsonPatchBodyOptionality(bool isOptional) + { + // Arrange + var builder = CreateBuilder(); + + // Act + if (isOptional) + { + builder.MapPatch("/", (JsonPatchDocument? patch) => { }); + } + else + { + builder.MapPatch("/", (JsonPatchDocument patch) => { }); + } + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var paths = Assert.Single(document.Paths.Values); + var operation = paths.Operations![HttpMethod.Patch]; + Assert.NotNull(operation.RequestBody); + Assert.Equal(!isOptional, operation.RequestBody.Required); + }); + + } +#nullable restore + + [Fact] + public async Task GetRequestBody_HandlesJsonPatchBodyWithAttribute() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPatch("/", ([FromBody] JsonPatchDocument patch) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var paths = Assert.Single(document.Paths.Values); + var operation = paths.Operations[HttpMethod.Patch]; + Assert.NotNull(operation.RequestBody); + Assert.False(operation.RequestBody.Required); + Assert.NotNull(operation.RequestBody.Content); + var content = Assert.Single(operation.RequestBody.Content); + Assert.Equal("application/json-patch+json", content.Key); + var schema = Assert.IsType(content.Value.Schema); + Assert.Equal("JsonPatchDocument", schema.Reference.Id); + }); + } + + [Fact] + public async Task GetRequestBody_HandlesJsonPatchBodyWithAcceptsMetadata() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPatch("/", (JsonPatchDocument name) => { }).Accepts(typeof(JsonPatchDocument), "application/vnd.github.patch+json"); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var paths = Assert.Single(document.Paths.Values); + var operation = paths.Operations[HttpMethod.Patch]; + Assert.NotNull(operation.RequestBody); + Assert.NotNull(operation.RequestBody.Content); + var content = Assert.Single(operation.RequestBody.Content); + Assert.Equal("application/vnd.github.patch+json", content.Key); + var schema = Assert.IsType(content.Value.Schema); + Assert.Equal("JsonPatchDocument", schema.Reference.Id); + }); + } + + [Fact] + public async Task GetRequestBody_HandlesJsonPatchBodyWithConsumesAttribute() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPatch("/", [Consumes(typeof(JsonPatchDocument), "application/vnd.github.patch+json")] (JsonPatchDocument patch) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var paths = Assert.Single(document.Paths.Values); + var operation = paths.Operations[HttpMethod.Patch]; + Assert.NotNull(operation.RequestBody); + Assert.NotNull(operation.RequestBody.Content); + var content = Assert.Single(operation.RequestBody.Content); + Assert.Equal("application/vnd.github.patch+json", content.Key); + var schema = Assert.IsType(content.Value.Schema); + Assert.Equal("JsonPatchDocument", schema.Reference.Id); + }); + } + + [Fact] + public async Task GetRequestBody_HandlesGenericJsonPatchBody() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPatch("/", (JsonPatchDocument patch) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var paths = Assert.Single(document.Paths.Values); + var operation = paths.Operations[HttpMethod.Patch]; + Assert.NotNull(operation.RequestBody); + Assert.False(operation.RequestBody.Required); + Assert.NotNull(operation.RequestBody.Content); + var content = Assert.Single(operation.RequestBody.Content); + Assert.Equal("application/json-patch+json", content.Key); + var schema = Assert.IsType(content.Value.Schema); + Assert.Equal("JsonPatchDocument", schema.Reference.Id); + }); + } + +#nullable enable + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task GetRequestBody_HandlesGenericJsonPatchBodyOptionality(bool isOptional) + { + // Arrange + var builder = CreateBuilder(); + + // Act + if (isOptional) + { + builder.MapPatch("/", (JsonPatchDocument? patch) => { }); + } + else + { + builder.MapPatch("/", (JsonPatchDocument patch) => { }); + } + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var paths = Assert.Single(document.Paths.Values); + var operation = paths.Operations![HttpMethod.Patch]; + Assert.NotNull(operation.RequestBody); + Assert.Equal(!isOptional, operation.RequestBody.Required); + }); + + } +#nullable restore + + [Fact] + public async Task GetRequestBody_HandlesGenericJsonPatchBodyWithAttribute() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPatch("/", ([FromBody] JsonPatchDocument patch) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var paths = Assert.Single(document.Paths.Values); + var operation = paths.Operations[HttpMethod.Patch]; + Assert.NotNull(operation.RequestBody); + Assert.False(operation.RequestBody.Required); + Assert.NotNull(operation.RequestBody.Content); + var content = Assert.Single(operation.RequestBody.Content); + Assert.Equal("application/json-patch+json", content.Key); + var schema = Assert.IsType(content.Value.Schema); + Assert.Equal("JsonPatchDocument", schema.Reference.Id); + }); + } + + [Fact] + public async Task GetRequestBody_HandlesGenericJsonPatchBodyWithAcceptsMetadata() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPatch("/", (JsonPatchDocument name) => { }) + .Accepts(typeof(JsonPatchDocument), "application/vnd.github.patch+json"); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var paths = Assert.Single(document.Paths.Values); + var operation = paths.Operations[HttpMethod.Patch]; + Assert.NotNull(operation.RequestBody); + Assert.NotNull(operation.RequestBody.Content); + var content = Assert.Single(operation.RequestBody.Content); + Assert.Equal("application/vnd.github.patch+json", content.Key); + var schema = Assert.IsType(content.Value.Schema); + Assert.Equal("JsonPatchDocument", schema.Reference.Id); + }); + } + + [Fact] + public async Task GetRequestBody_HandlesGenericJsonPatchBodyWithConsumesAttribute() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPatch("/", [Consumes(typeof(JsonPatchDocument), "application/vnd.github.patch+json")] (JsonPatchDocument patch) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var paths = Assert.Single(document.Paths.Values); + var operation = paths.Operations[HttpMethod.Patch]; + Assert.NotNull(operation.RequestBody); + Assert.NotNull(operation.RequestBody.Content); + var content = Assert.Single(operation.RequestBody.Content); + Assert.Equal("application/vnd.github.patch+json", content.Key); + var schema = Assert.IsType(content.Value.Schema); + Assert.Equal("JsonPatchDocument", schema.Reference.Id); + }); + } + +#nullable enable + private sealed class JsonPatchModel + { + [JsonPropertyName("first")] + public string? First { get; set; } + + [JsonPropertyName("second")] + public string? Second { get; set; } + } +#nullable restore + + [Fact] + public async Task GetRequestBody_HandlesCustomJsonPatchBody() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPatch("/", (CustomJsonPatchDocument patch) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var paths = Assert.Single(document.Paths.Values); + var operation = paths.Operations[HttpMethod.Patch]; + Assert.NotNull(operation.RequestBody); + Assert.False(operation.RequestBody.Required); + Assert.NotNull(operation.RequestBody.Content); + var content = Assert.Single(operation.RequestBody.Content); + Assert.Equal("application/json-patch+json", content.Key); + var schema = Assert.IsType(content.Value.Schema); + Assert.Equal("JsonPatchDocument", schema.Reference.Id); + }); + } + + private class CustomJsonPatchDocument : JsonPatchDocument; + + [Fact] + public async Task GetRequestBody_HandlesGenericCustomJsonPatchBody() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPatch("/", (CustomJsonPatchDocument patch) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var paths = Assert.Single(document.Paths.Values); + var operation = paths.Operations[HttpMethod.Patch]; + Assert.NotNull(operation.RequestBody); + Assert.False(operation.RequestBody.Required); + Assert.NotNull(operation.RequestBody.Content); + var content = Assert.Single(operation.RequestBody.Content); + Assert.Equal("application/json-patch+json", content.Key); + var schema = Assert.IsType(content.Value.Schema); + Assert.Equal("JsonPatchDocument", schema.Reference.Id); + }); + } + + private class CustomJsonPatchDocument : JsonPatchDocument where T : class; }