From 9b7d34131ba8697c59c504192ec216eda38bab13 Mon Sep 17 00:00:00 2001 From: martincostello Date: Sat, 2 Aug 2025 12:37:16 +0100 Subject: [PATCH 1/7] [OpenApi] Add non-generic JSON patch Update the sample to include a non-generic JSON patch endpoint. --- .../sample/Endpoints/MapSchemasEndpoints.cs | 3 ++- ...t_documentName=schemas-by-ref.verified.txt | 23 +++++++++++++++++++ ...t_documentName=schemas-by-ref.verified.txt | 23 +++++++++++++++++++ ...ifyOpenApiDocumentIsInvariant.verified.txt | 23 +++++++++++++++++++ 4 files changed, 71 insertions(+), 1 deletion(-) 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/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..252faf1f5cf2 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 @@ -509,6 +509,28 @@ } }, "/schemas-by-ref/json-patch": { + "patch": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/JsonPatchDocument" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/json-patch-generic": { "patch": { "tags": [ "Sample" @@ -621,6 +643,7 @@ } } }, + "JsonPatchDocument": { }, "JsonPatchDocumentOfParentObject": { }, "LocationContainer": { "required": [ 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..35f51548b1e9 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 @@ -509,6 +509,28 @@ } }, "/schemas-by-ref/json-patch": { + "patch": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/JsonPatchDocument" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/json-patch-generic": { "patch": { "tags": [ "Sample" @@ -621,6 +643,7 @@ } } }, + "JsonPatchDocument": { }, "JsonPatchDocumentOfParentObject": { }, "LocationContainer": { "required": [ 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..6cda4694efcd 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 @@ -1034,6 +1034,28 @@ } }, "/schemas-by-ref/json-patch": { + "patch": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/JsonPatchDocument" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/json-patch-generic": { "patch": { "tags": [ "Sample" @@ -1410,6 +1432,7 @@ } } }, + "JsonPatchDocument": { }, "JsonPatchDocumentOfParentObject": { }, "LocationContainer": { "required": [ From 5e7fffef5f2418f96a99e8cdebbf5f0ee3799743 Mon Sep 17 00:00:00 2001 From: martincostello Date: Sat, 2 Aug 2025 14:29:28 +0100 Subject: [PATCH 2/7] [OpenApi] Generate schema for JSON Patch Generate an appropriate OpenAPI schema for JSON Patch endpoints. --- .../src/Extensions/JsonTypeInfoExtensions.cs | 9 + .../src/Microsoft.AspNetCore.OpenApi.csproj | 9 +- .../src/Schemas/OpenApiJsonSchema.Helpers.cs | 10 +- .../src/Schemas/OpenApiSchemaKeywords.cs | 1 + .../src/Services/OpenApiDocumentService.cs | 7 + src/OpenApi/src/Services/OpenApiGenerator.cs | 6 +- .../Services/Schemas/OpenApiSchemaService.cs | 99 ++++++- .../Extensions/JsonTypeInfoExtensionsTests.cs | 3 + ...t_documentName=schemas-by-ref.verified.txt | 74 +++++- ...t_documentName=schemas-by-ref.verified.txt | 74 +++++- ...ifyOpenApiDocumentIsInvariant.verified.txt | 74 +++++- ...OpenApiDocumentServiceTests.RequestBody.cs | 248 ++++++++++++++++++ 12 files changed, 596 insertions(+), 18 deletions(-) diff --git a/src/OpenApi/src/Extensions/JsonTypeInfoExtensions.cs b/src/OpenApi/src/Extensions/JsonTypeInfoExtensions.cs index e09e2626444c..4e1278ee721e 100644 --- a/src/OpenApi/src/Extensions/JsonTypeInfoExtensions.cs +++ b/src/OpenApi/src/Extensions/JsonTypeInfoExtensions.cs @@ -6,6 +6,7 @@ using System.Text.Json; using System.Text.Json.Serialization.Metadata; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson; namespace Microsoft.AspNetCore.OpenApi; @@ -32,6 +33,7 @@ internal static class JsonTypeInfoExtensions [typeof(string)] = "string", [typeof(IFormFile)] = "IFormFile", [typeof(IFormFileCollection)] = "IFormFileCollection", + [typeof(JsonPatchDocument)] = "JsonPatchDocument", [typeof(PipeReader)] = "PipeReader", [typeof(Stream)] = "Stream" }; @@ -66,6 +68,13 @@ internal static class JsonTypeInfoExtensions return simpleName; } + // Use the same JSON Patch schema for all generic JsonPatchDocument types + // as otherwise we'll generate a schema per type argument which are otherwise identical. + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(JsonPatchDocument<>)) + { + return _simpleTypeToName[typeof(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/Microsoft.AspNetCore.OpenApi.csproj b/src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj index 7bf5362ca205..6c5fc2c66294 100644 --- a/src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj +++ b/src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj @@ -14,13 +14,14 @@ - + - - + - + + + 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..394683109dcc 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentService.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs @@ -17,6 +17,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.Mvc.Infrastructure; @@ -720,6 +721,12 @@ private async Task GetJsonRequestBody( // for stream-based parameter types. supportedRequestFormats = [new ApiRequestFormat { MediaType = "application/octet-stream" }]; } + else if (bodyParameter.Type == typeof(JsonPatchDocument) || (bodyParameter.Type.IsGenericType && bodyParameter.Type.GetGenericTypeDefinition() == typeof(JsonPatchDocument<>))) + { + // 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..5ebee54705c8 100644 --- a/src/OpenApi/src/Services/OpenApiGenerator.cs +++ b/src/OpenApi/src/Services/OpenApiGenerator.cs @@ -9,6 +9,7 @@ using System.Security.Claims; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.Mvc.Formatters; @@ -447,7 +448,10 @@ 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 == typeof(JsonPatchDocument) || + parameter.ParameterType == typeof(JsonPatchDocument<>)) { return (true, null, null); } diff --git a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs index 10737cbd5f20..35c05050e5f6 100644 --- a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs +++ b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs @@ -13,6 +13,7 @@ using System.Text.Json.Serialization.Metadata; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Json; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson; using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.Extensions.DependencyInjection; @@ -82,6 +83,10 @@ internal sealed class OpenApiSchemaService( } }; } + else if (type == typeof(JsonPatchDocument) || (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(JsonPatchDocument<>))) + { + 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 +122,95 @@ 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.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 252faf1f5cf2..c5b40d93e298 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 @@ -539,7 +539,7 @@ "content": { "application/json-patch+json": { "schema": { - "$ref": "#/components/schemas/JsonPatchDocumentOfParentObject" + "$ref": "#/components/schemas/JsonPatchDocument" } } }, @@ -643,8 +643,76 @@ } } }, - "JsonPatchDocument": { }, - "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 + }, + { + "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 35f51548b1e9..5996af773eae 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 @@ -539,7 +539,7 @@ "content": { "application/json-patch+json": { "schema": { - "$ref": "#/components/schemas/JsonPatchDocumentOfParentObject" + "$ref": "#/components/schemas/JsonPatchDocument" } } }, @@ -643,8 +643,76 @@ } } }, - "JsonPatchDocument": { }, - "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 + }, + { + "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 6cda4694efcd..923417a22811 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 @@ -1064,7 +1064,7 @@ "content": { "application/json-patch+json": { "schema": { - "$ref": "#/components/schemas/JsonPatchDocumentOfParentObject" + "$ref": "#/components/schemas/JsonPatchDocument" } } }, @@ -1432,8 +1432,76 @@ } } }, - "JsonPatchDocument": { }, - "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 + }, + { + "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..4579c4f66334 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,250 @@ 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); + }); + } + +#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); + }); + } + + [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); + }); + } + + [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); + }); + } + + [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); + }); + } + +#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); + }); + } + + [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); + }); + } + + [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); + }); + } + +#nullable enable + private sealed class JsonPatchModel + { + [JsonPropertyName("first")] + public string? First { get; set; } + + [JsonPropertyName("second")] + public string? Second { get; set; } + } +#nullable restore } From 449d575f0536be96f4ecca3996b113c3a2605242 Mon Sep 17 00:00:00 2001 From: martincostello Date: Sat, 2 Aug 2025 14:48:29 +0100 Subject: [PATCH 3/7] Fix condition Leftovers from before tests were added. --- src/OpenApi/src/Services/OpenApiGenerator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OpenApi/src/Services/OpenApiGenerator.cs b/src/OpenApi/src/Services/OpenApiGenerator.cs index 5ebee54705c8..b71c4903538b 100644 --- a/src/OpenApi/src/Services/OpenApiGenerator.cs +++ b/src/OpenApi/src/Services/OpenApiGenerator.cs @@ -451,7 +451,7 @@ private List GetOpenApiParameters(MethodInfo methodInfo, Rout else if (parameter.ParameterType == typeof(IFormFile) || parameter.ParameterType == typeof(IFormFileCollection) || parameter.ParameterType == typeof(JsonPatchDocument) || - parameter.ParameterType == typeof(JsonPatchDocument<>)) + (parameter.ParameterType.IsGenericType && parameter.ParameterType.GetGenericTypeDefinition() == typeof(JsonPatchDocument<>))) { return (true, null, null); } From fe2dba7b6b0ffd6df52955bc7bf9d3891222920c Mon Sep 17 00:00:00 2001 From: martincostello Date: Sat, 2 Aug 2025 15:16:01 +0100 Subject: [PATCH 4/7] Handle derived types - Handle custom types derived from `JsonPatchDocument` or `JsonPatchDocument`. - Add extension method to remove duplicated type checks. --- .../src/Extensions/JsonTypeInfoExtensions.cs | 11 ++- src/OpenApi/src/Extensions/TypeExtensions.cs | 31 +++++++++ .../src/Services/OpenApiDocumentService.cs | 3 +- src/OpenApi/src/Services/OpenApiGenerator.cs | 4 +- .../Services/Schemas/OpenApiSchemaService.cs | 3 +- ...OpenApiDocumentServiceTests.RequestBody.cs | 68 +++++++++++++++++++ 6 files changed, 107 insertions(+), 13 deletions(-) create mode 100644 src/OpenApi/src/Extensions/TypeExtensions.cs diff --git a/src/OpenApi/src/Extensions/JsonTypeInfoExtensions.cs b/src/OpenApi/src/Extensions/JsonTypeInfoExtensions.cs index 4e1278ee721e..be025a61b529 100644 --- a/src/OpenApi/src/Extensions/JsonTypeInfoExtensions.cs +++ b/src/OpenApi/src/Extensions/JsonTypeInfoExtensions.cs @@ -6,7 +6,6 @@ using System.Text.Json; using System.Text.Json.Serialization.Metadata; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.JsonPatch.SystemTextJson; namespace Microsoft.AspNetCore.OpenApi; @@ -33,7 +32,6 @@ internal static class JsonTypeInfoExtensions [typeof(string)] = "string", [typeof(IFormFile)] = "IFormFile", [typeof(IFormFileCollection)] = "IFormFileCollection", - [typeof(JsonPatchDocument)] = "JsonPatchDocument", [typeof(PipeReader)] = "PipeReader", [typeof(Stream)] = "Stream" }; @@ -68,11 +66,12 @@ internal static class JsonTypeInfoExtensions return simpleName; } - // Use the same JSON Patch schema for all generic JsonPatchDocument types - // as otherwise we'll generate a schema per type argument which are otherwise identical. - if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(JsonPatchDocument<>)) + // 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 _simpleTypeToName[typeof(JsonPatchDocument)]; + return "JsonPatchDocument"; } // Although arrays are enumerable types they are not encoded correctly diff --git a/src/OpenApi/src/Extensions/TypeExtensions.cs b/src/OpenApi/src/Extensions/TypeExtensions.cs new file mode 100644 index 000000000000..7b7fbf883466 --- /dev/null +++ b/src/OpenApi/src/Extensions/TypeExtensions.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.JsonPatch.SystemTextJson; + +namespace Microsoft.AspNetCore.OpenApi; + +internal static class TypeExtensions +{ + public static bool IsJsonPatchDocument(this Type type) + { + if (type.IsAssignableTo(typeof(JsonPatchDocument))) + { + return true; + } + + var modelType = type; + + while (modelType != null && modelType != typeof(object)) + { + if (modelType.IsGenericType && modelType.GetGenericTypeDefinition() == typeof(JsonPatchDocument<>)) + { + return true; + } + + modelType = modelType.BaseType; + } + + return false; + } +} diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs index 394683109dcc..5308dc3792a1 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentService.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs @@ -17,7 +17,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Http.Metadata; -using Microsoft.AspNetCore.JsonPatch.SystemTextJson; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.Mvc.Infrastructure; @@ -721,7 +720,7 @@ private async Task GetJsonRequestBody( // for stream-based parameter types. supportedRequestFormats = [new ApiRequestFormat { MediaType = "application/octet-stream" }]; } - else if (bodyParameter.Type == typeof(JsonPatchDocument) || (bodyParameter.Type.IsGenericType && bodyParameter.Type.GetGenericTypeDefinition() == typeof(JsonPatchDocument<>))) + else if (bodyParameter.Type.IsJsonPatchDocument()) { // Assume "application/json-patch+json" as the default media type // for JSON Patch documents. diff --git a/src/OpenApi/src/Services/OpenApiGenerator.cs b/src/OpenApi/src/Services/OpenApiGenerator.cs index b71c4903538b..6257f7fa2fea 100644 --- a/src/OpenApi/src/Services/OpenApiGenerator.cs +++ b/src/OpenApi/src/Services/OpenApiGenerator.cs @@ -9,7 +9,6 @@ using System.Security.Claims; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Metadata; -using Microsoft.AspNetCore.JsonPatch.SystemTextJson; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.Mvc.Formatters; @@ -450,8 +449,7 @@ private List GetOpenApiParameters(MethodInfo methodInfo, Rout } else if (parameter.ParameterType == typeof(IFormFile) || parameter.ParameterType == typeof(IFormFileCollection) || - parameter.ParameterType == typeof(JsonPatchDocument) || - (parameter.ParameterType.IsGenericType && parameter.ParameterType.GetGenericTypeDefinition() == typeof(JsonPatchDocument<>))) + 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 35c05050e5f6..4246eb3825f2 100644 --- a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs +++ b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs @@ -13,7 +13,6 @@ using System.Text.Json.Serialization.Metadata; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Json; -using Microsoft.AspNetCore.JsonPatch.SystemTextJson; using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.Extensions.DependencyInjection; @@ -83,7 +82,7 @@ internal sealed class OpenApiSchemaService( } }; } - else if (type == typeof(JsonPatchDocument) || (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(JsonPatchDocument<>))) + else if (type.IsJsonPatchDocument()) { schema = CreateSchemaForJsonPatch(); } 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 4579c4f66334..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 @@ -1153,6 +1153,8 @@ await VerifyOpenApiDocument(builder, document => 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); }); } @@ -1206,6 +1208,8 @@ await VerifyOpenApiDocument(builder, document => 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); }); } @@ -1227,6 +1231,8 @@ await VerifyOpenApiDocument(builder, document => 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); }); } @@ -1248,6 +1254,8 @@ await VerifyOpenApiDocument(builder, document => 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); }); } @@ -1270,6 +1278,8 @@ await VerifyOpenApiDocument(builder, document => 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); }); } @@ -1323,6 +1333,8 @@ await VerifyOpenApiDocument(builder, document => 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); }); } @@ -1345,6 +1357,8 @@ await VerifyOpenApiDocument(builder, document => 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); }); } @@ -1366,6 +1380,8 @@ await VerifyOpenApiDocument(builder, document => 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); }); } @@ -1379,4 +1395,56 @@ private sealed class JsonPatchModel 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; } From bf9b7ddee95d0a9a33287eb86936e37188fc335f Mon Sep 17 00:00:00 2001 From: martincostello Date: Sat, 2 Aug 2025 18:26:45 +0100 Subject: [PATCH 5/7] [OpenApi] Remove JsonPatch.SystemTextJson dep Remove the dependency on `Microsoft.AspNetCore.JsonPatch.SystemTextJson` as it creates type warnings, and instead check the types by name. --- src/OpenApi/src/Extensions/TypeExtensions.cs | 17 +++++++++-------- .../src/Microsoft.AspNetCore.OpenApi.csproj | 1 - 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/OpenApi/src/Extensions/TypeExtensions.cs b/src/OpenApi/src/Extensions/TypeExtensions.cs index 7b7fbf883466..25ca58ba6686 100644 --- a/src/OpenApi/src/Extensions/TypeExtensions.cs +++ b/src/OpenApi/src/Extensions/TypeExtensions.cs @@ -1,24 +1,25 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Microsoft.AspNetCore.JsonPatch.SystemTextJson; - 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) { - if (type.IsAssignableTo(typeof(JsonPatchDocument))) - { - return true; - } - + // 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.IsGenericType && modelType.GetGenericTypeDefinition() == typeof(JsonPatchDocument<>)) + if (modelType.Namespace == JsonPatchDocumentNamespace && + (modelType.Name == JsonPatchDocumentName || modelType.Name.StartsWith(JsonPatchDocumentNameOfT, StringComparison.Ordinal))) { return true; } diff --git a/src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj b/src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj index 6c5fc2c66294..66aa82bbf270 100644 --- a/src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj +++ b/src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj @@ -17,7 +17,6 @@ - From d8e29a629c30fa8ce4b896c9cc1ff173bc8fe01a Mon Sep 17 00:00:00 2001 From: martincostello Date: Sun, 3 Aug 2025 09:50:31 +0100 Subject: [PATCH 6/7] Update type check - Check for a generic type before type name check. - Replace `StartsWith()` with `==`. --- src/OpenApi/src/Extensions/TypeExtensions.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/OpenApi/src/Extensions/TypeExtensions.cs b/src/OpenApi/src/Extensions/TypeExtensions.cs index 25ca58ba6686..e2ba5f500c63 100644 --- a/src/OpenApi/src/Extensions/TypeExtensions.cs +++ b/src/OpenApi/src/Extensions/TypeExtensions.cs @@ -19,7 +19,8 @@ public static bool IsJsonPatchDocument(this Type type) while (modelType != null && modelType != typeof(object)) { if (modelType.Namespace == JsonPatchDocumentNamespace && - (modelType.Name == JsonPatchDocumentName || modelType.Name.StartsWith(JsonPatchDocumentNameOfT, StringComparison.Ordinal))) + (modelType.Name == JsonPatchDocumentName || + (modelType.IsGenericType && modelType.GenericTypeArguments.Length == 1 && modelType.Name == JsonPatchDocumentNameOfT))) { return true; } From f91de94be74398a75ccc2ddd724e76bf4c051667 Mon Sep 17 00:00:00 2001 From: martincostello Date: Sun, 3 Aug 2025 09:55:40 +0100 Subject: [PATCH 7/7] Add required for JSON Patch remove Add `required` members to the OpenAPI schema for a JSON Patch remove operation. --- src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs | 1 + ...fyOpenApiDocument_documentName=schemas-by-ref.verified.txt | 4 ++++ ...fyOpenApiDocument_documentName=schemas-by-ref.verified.txt | 4 ++++ ...izationTests.VerifyOpenApiDocumentIsInvariant.verified.txt | 4 ++++ 4 files changed, 13 insertions(+) diff --git a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs index 4246eb3825f2..2971ab2e9b6c 100644 --- a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs +++ b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs @@ -170,6 +170,7 @@ private static JsonObject CreateSchemaForJsonPatch() { [OpenApiSchemaKeywords.TypeKeyword] = "object", [OpenApiSchemaKeywords.AdditionalPropertiesKeyword] = false, + [OpenApiSchemaKeywords.RequiredKeyword] = JsonArray(["op", "path"]), [OpenApiSchemaKeywords.PropertiesKeyword] = new JsonObject { ["op"] = new JsonObject() 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 c5b40d93e298..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 @@ -696,6 +696,10 @@ "additionalProperties": false }, { + "required": [ + "op", + "path" + ], "type": "object", "properties": { "op": { 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 5996af773eae..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 @@ -696,6 +696,10 @@ "additionalProperties": false }, { + "required": [ + "op", + "path" + ], "type": "object", "properties": { "op": { 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 923417a22811..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 @@ -1485,6 +1485,10 @@ "additionalProperties": false }, { + "required": [ + "op", + "path" + ], "type": "object", "properties": { "op": {