From 4f0673b04b8fb4463778d4e57184ffcc2d5bfa96 Mon Sep 17 00:00:00 2001 From: martincostello Date: Tue, 29 Jul 2025 14:33:03 +0100 Subject: [PATCH] Update media type for JSON Patch - Use `Content-Type: application/json-patch+json` for JSON patch. - Fix some code analyzer suggestions. - Fix broken `build.cmd` for JsonPatch. Adapted from #62057. Resolves #61956. --- .../src/JsonPatchDocument.cs | 29 ++++++++++----- .../src/JsonPatchDocumentOfT.cs | 17 +++++++-- ...AspNetCore.JsonPatch.SystemTextJson.csproj | 4 +++ src/Features/JsonPatch/build.cmd | 2 +- .../JsonPatch/src/JsonPatchDocument.cs | 36 +++++++++++++++---- .../JsonPatch/src/JsonPatchDocumentOfT.cs | 28 ++++++++++++--- .../src/Microsoft.AspNetCore.JsonPatch.csproj | 1 + .../sample/Endpoints/MapSchemasEndpoints.cs | 2 ++ src/OpenApi/sample/Sample.csproj | 5 +-- ...t_documentName=schemas-by-ref.verified.txt | 23 ++++++++++++ ...t_documentName=schemas-by-ref.verified.txt | 23 ++++++++++++ 11 files changed, 145 insertions(+), 25 deletions(-) diff --git a/src/Features/JsonPatch.SystemTextJson/src/JsonPatchDocument.cs b/src/Features/JsonPatch.SystemTextJson/src/JsonPatchDocument.cs index c57febaff4e9..684ef7a8d28d 100644 --- a/src/Features/JsonPatch.SystemTextJson/src/JsonPatchDocument.cs +++ b/src/Features/JsonPatch.SystemTextJson/src/JsonPatchDocument.cs @@ -3,8 +3,11 @@ using System; using System.Collections.Generic; +using System.Reflection; using System.Text.Json; using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Adapters; using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Converters; using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Exceptions; @@ -18,7 +21,7 @@ namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson; // documents for cases where there's no class/DTO to work on. Typical use case: backend not built in // .NET or architecture doesn't contain a shared DTO layer. [JsonConverter(typeof(JsonPatchDocumentConverter))] -public class JsonPatchDocument : IJsonPatchDocument +public class JsonPatchDocument : IJsonPatchDocument, IEndpointParameterMetadataProvider { public List Operations { get; private set; } @@ -27,7 +30,7 @@ public class JsonPatchDocument : IJsonPatchDocument public JsonPatchDocument() { - Operations = new List(); + Operations = []; SerializerOptions = JsonSerializerOptions.Default; } @@ -205,12 +208,13 @@ IList IJsonPatchDocument.GetOperations() { foreach (var op in Operations) { - var untypedOp = new Operation(); - - untypedOp.op = op.op; - untypedOp.value = op.value; - untypedOp.path = op.path; - untypedOp.from = op.from; + var untypedOp = new Operation + { + op = op.op, + value = op.value, + path = op.path, + from = op.from + }; allOps.Add(untypedOp); } @@ -218,4 +222,13 @@ IList IJsonPatchDocument.GetOperations() return allOps; } + + /// + static void IEndpointParameterMetadataProvider.PopulateMetadata(ParameterInfo parameter, EndpointBuilder builder) + { + ArgumentNullException.ThrowIfNull(parameter); + ArgumentNullException.ThrowIfNull(builder); + + builder.Metadata.Add(new AcceptsMetadata(["application/json-patch+json"], parameter.ParameterType)); + } } diff --git a/src/Features/JsonPatch.SystemTextJson/src/JsonPatchDocumentOfT.cs b/src/Features/JsonPatch.SystemTextJson/src/JsonPatchDocumentOfT.cs index afb3f9ab0858..a9f1671ce8e6 100644 --- a/src/Features/JsonPatch.SystemTextJson/src/JsonPatchDocumentOfT.cs +++ b/src/Features/JsonPatch.SystemTextJson/src/JsonPatchDocumentOfT.cs @@ -9,6 +9,8 @@ using System.Reflection; using System.Text.Json; using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Adapters; using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Converters; using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Exceptions; @@ -23,7 +25,7 @@ namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson; // including type data in the JsonPatchDocument serialized as JSON (to allow for correct deserialization) - that's // not according to RFC 6902, and would thus break cross-platform compatibility. [JsonConverter(typeof(JsonPatchDocumentConverterFactory))] -public class JsonPatchDocument : IJsonPatchDocument where TModel : class +public class JsonPatchDocument : IJsonPatchDocument, IEndpointParameterMetadataProvider where TModel : class { public List> Operations { get; private set; } @@ -32,7 +34,7 @@ public class JsonPatchDocument : IJsonPatchDocument where TModel : class public JsonPatchDocument() { - Operations = new List>(); + Operations = []; SerializerOptions = JsonSerializerOptions.Default; } @@ -657,11 +659,20 @@ IList IJsonPatchDocument.GetOperations() return allOps; } + /// + static void IEndpointParameterMetadataProvider.PopulateMetadata(ParameterInfo parameter, EndpointBuilder builder) + { + ArgumentNullException.ThrowIfNull(parameter); + ArgumentNullException.ThrowIfNull(builder); + + builder.Metadata.Add(new AcceptsMetadata(["application/json-patch+json"], typeof(TModel))); + } + // Internal for testing internal string GetPath(Expression> expr, string position) { var segments = GetPathSegments(expr.Body); - var path = String.Join("/", segments); + var path = string.Join('/', segments); if (position != null) { path += "/" + position; diff --git a/src/Features/JsonPatch.SystemTextJson/src/Microsoft.AspNetCore.JsonPatch.SystemTextJson.csproj b/src/Features/JsonPatch.SystemTextJson/src/Microsoft.AspNetCore.JsonPatch.SystemTextJson.csproj index 8174fe7c0c92..434a8cf7373a 100644 --- a/src/Features/JsonPatch.SystemTextJson/src/Microsoft.AspNetCore.JsonPatch.SystemTextJson.csproj +++ b/src/Features/JsonPatch.SystemTextJson/src/Microsoft.AspNetCore.JsonPatch.SystemTextJson.csproj @@ -16,6 +16,10 @@ + + + + diff --git a/src/Features/JsonPatch/build.cmd b/src/Features/JsonPatch/build.cmd index 5274e00264f0..a71232783bb2 100644 --- a/src/Features/JsonPatch/build.cmd +++ b/src/Features/JsonPatch/build.cmd @@ -1,3 +1,3 @@ @ECHO OFF -SET RepoRoot=%~dp0..\.. +SET RepoRoot=%~dp0..\..\.. %RepoRoot%\eng\build.cmd -projects %~dp0**\*.*proj %* diff --git a/src/Features/JsonPatch/src/JsonPatchDocument.cs b/src/Features/JsonPatch/src/JsonPatchDocument.cs index 5b9152ccdb12..e3f79c455806 100644 --- a/src/Features/JsonPatch/src/JsonPatchDocument.cs +++ b/src/Features/JsonPatch/src/JsonPatchDocument.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Reflection; using Microsoft.AspNetCore.JsonPatch.Adapters; using Microsoft.AspNetCore.JsonPatch.Converters; using Microsoft.AspNetCore.JsonPatch.Exceptions; @@ -12,13 +13,22 @@ using Newtonsoft.Json; using Newtonsoft.Json.Serialization; +#if NET +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http.Metadata; +#endif + namespace Microsoft.AspNetCore.JsonPatch; // Implementation details: the purpose of this type of patch document is to allow creation of such // documents for cases where there's no class/DTO to work on. Typical use case: backend not built in // .NET or architecture doesn't contain a shared DTO layer. [JsonConverter(typeof(JsonPatchDocumentConverter))] +#if NET +public class JsonPatchDocument : IJsonPatchDocument, IEndpointParameterMetadataProvider +#else public class JsonPatchDocument : IJsonPatchDocument +#endif { public List Operations { get; private set; } @@ -27,7 +37,7 @@ public class JsonPatchDocument : IJsonPatchDocument public JsonPatchDocument() { - Operations = new List(); + Operations = []; ContractResolver = new DefaultContractResolver(); } @@ -205,12 +215,13 @@ IList IJsonPatchDocument.GetOperations() { foreach (var op in Operations) { - var untypedOp = new Operation(); - - untypedOp.op = op.op; - untypedOp.value = op.value; - untypedOp.path = op.path; - untypedOp.from = op.from; + var untypedOp = new Operation + { + op = op.op, + value = op.value, + path = op.path, + from = op.from + }; allOps.Add(untypedOp); } @@ -218,4 +229,15 @@ IList IJsonPatchDocument.GetOperations() return allOps; } + +#if NET + /// + static void IEndpointParameterMetadataProvider.PopulateMetadata(ParameterInfo parameter, EndpointBuilder builder) + { + ArgumentNullException.ThrowIfNull(parameter); + ArgumentNullException.ThrowIfNull(builder); + + builder.Metadata.Add(new AcceptsMetadata(["application/json-patch+json"], parameter.ParameterType)); + } +#endif } diff --git a/src/Features/JsonPatch/src/JsonPatchDocumentOfT.cs b/src/Features/JsonPatch/src/JsonPatchDocumentOfT.cs index a5340ef515c4..afefe6b4a9d1 100644 --- a/src/Features/JsonPatch/src/JsonPatchDocumentOfT.cs +++ b/src/Features/JsonPatch/src/JsonPatchDocumentOfT.cs @@ -6,6 +6,7 @@ using System.Globalization; using System.Linq; using System.Linq.Expressions; +using System.Reflection; using Microsoft.AspNetCore.JsonPatch.Adapters; using Microsoft.AspNetCore.JsonPatch.Converters; using Microsoft.AspNetCore.JsonPatch.Exceptions; @@ -15,6 +16,11 @@ using Newtonsoft.Json; using Newtonsoft.Json.Serialization; +#if NET +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http.Metadata; +#endif + namespace Microsoft.AspNetCore.JsonPatch; // Implementation details: the purpose of this type of patch document is to ensure we can do type-checking @@ -22,7 +28,11 @@ namespace Microsoft.AspNetCore.JsonPatch; // including type data in the JsonPatchDocument serialized as JSON (to allow for correct deserialization) - that's // not according to RFC 6902, and would thus break cross-platform compatibility. [JsonConverter(typeof(TypedJsonPatchDocumentConverter))] +#if NET +public class JsonPatchDocument : IJsonPatchDocument, IEndpointParameterMetadataProvider where TModel : class +#else public class JsonPatchDocument : IJsonPatchDocument where TModel : class +#endif { public List> Operations { get; private set; } @@ -31,7 +41,7 @@ public class JsonPatchDocument : IJsonPatchDocument where TModel : class public JsonPatchDocument() { - Operations = new List>(); + Operations = []; ContractResolver = new DefaultContractResolver(); } @@ -656,11 +666,22 @@ IList IJsonPatchDocument.GetOperations() return allOps; } +#if NET + /// + static void IEndpointParameterMetadataProvider.PopulateMetadata(ParameterInfo parameter, EndpointBuilder builder) + { + ArgumentNullException.ThrowIfNull(parameter); + ArgumentNullException.ThrowIfNull(builder); + + builder.Metadata.Add(new AcceptsMetadata(["application/json-patch+json"], typeof(TModel))); + } +#endif + // Internal for testing internal string GetPath(Expression> expr, string position) { var segments = GetPathSegments(expr.Body); - var path = String.Join("/", segments); + var path = string.Join("/", segments); if (position != null) { path += "/" + position; @@ -712,8 +733,7 @@ private List GetPathSegments(Expression expr) private string GetPropertyNameFromMemberExpression(MemberExpression memberExpression) { - var jsonObjectContract = ContractResolver.ResolveContract(memberExpression.Expression.Type) as JsonObjectContract; - if (jsonObjectContract != null) + if (ContractResolver.ResolveContract(memberExpression.Expression.Type) is JsonObjectContract jsonObjectContract) { return jsonObjectContract.Properties .First(jsonProperty => jsonProperty.UnderlyingName == memberExpression.Member.Name) diff --git a/src/Features/JsonPatch/src/Microsoft.AspNetCore.JsonPatch.csproj b/src/Features/JsonPatch/src/Microsoft.AspNetCore.JsonPatch.csproj index f4027ea5fd71..65f81617bc6e 100644 --- a/src/Features/JsonPatch/src/Microsoft.AspNetCore.JsonPatch.csproj +++ b/src/Features/JsonPatch/src/Microsoft.AspNetCore.JsonPatch.csproj @@ -24,6 +24,7 @@ + diff --git a/src/OpenApi/sample/Endpoints/MapSchemasEndpoints.cs b/src/OpenApi/sample/Endpoints/MapSchemasEndpoints.cs index ee1840d07164..5aea239062db 100644 --- a/src/OpenApi/sample/Endpoints/MapSchemasEndpoints.cs +++ b/src/OpenApi/sample/Endpoints/MapSchemasEndpoints.cs @@ -4,6 +4,7 @@ using System.Collections.Immutable; using System.ComponentModel; using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson; public static class SchemasEndpointsExtensions { @@ -36,6 +37,7 @@ 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()); return endpointRouteBuilder; } diff --git a/src/OpenApi/sample/Sample.csproj b/src/OpenApi/sample/Sample.csproj index bc97ebe3f173..cb2dc20c05a5 100644 --- a/src/OpenApi/sample/Sample.csproj +++ b/src/OpenApi/sample/Sample.csproj @@ -16,12 +16,13 @@ - + + + - 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 788ea787fa14..0eecf7e47ee5 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 @@ -507,6 +507,28 @@ } } } + }, + "/schemas-by-ref/json-patch": { + "patch": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/JsonPatchDocumentOfParentObject" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } } }, "components": { @@ -599,6 +621,7 @@ } } }, + "JsonPatchDocumentOfParentObject": { }, "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 326da7fb0fb7..166b57e6aaab 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 @@ -507,6 +507,28 @@ } } } + }, + "/schemas-by-ref/json-patch": { + "patch": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/JsonPatchDocumentOfParentObject" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } } }, "components": { @@ -599,6 +621,7 @@ } } }, + "JsonPatchDocumentOfParentObject": { }, "LocationContainer": { "required": [ "location"