Skip to content

[OpenApi] Generate schema for JSON Patch endpoints #63052

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Aug 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/OpenApi/sample/Endpoints/MapSchemasEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ParentObject> patchDoc) => Results.NoContent());
schemas.MapPatch("/json-patch", (JsonPatchDocument patchDoc) => Results.NoContent());
schemas.MapPatch("/json-patch-generic", (JsonPatchDocument<ParentObject> patchDoc) => Results.NoContent());

return endpointRouteBuilder;
}
Expand Down
8 changes: 8 additions & 0 deletions src/OpenApi/src/Extensions/JsonTypeInfoExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@ internal static class JsonTypeInfoExtensions
return simpleName;
}

// Use the same JSON Patch schema for all JSON Patch document types (JsonPatchDocument,
// JsonPatchDocument<T>, 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.
Expand Down
33 changes: 33 additions & 0 deletions src/OpenApi/src/Extensions/TypeExtensions.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
8 changes: 4 additions & 4 deletions src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@
</PropertyGroup>

<ItemGroup>
<Reference Include="Microsoft.OpenApi" />
<Reference Include="Microsoft.AspNetCore" />
<Reference Include="Microsoft.AspNetCore.Http.Abstractions" />
<Reference Include="Microsoft.AspNetCore.Http.Results" />
<Reference Include="Microsoft.AspNetCore.Routing" />
<Reference Include="Microsoft.AspNetCore.Mvc.Core" />
<Reference Include="Microsoft.AspNetCore.Mvc.ApiExplorer" />
<Reference Include="Microsoft.AspNetCore" />
<Reference Include="Microsoft.AspNetCore.Mvc.Core" />
<Reference Include="Microsoft.AspNetCore.Routing" />
<Reference Include="Microsoft.OpenApi" />
</ItemGroup>

<ItemGroup>
Expand Down
10 changes: 8 additions & 2 deletions src/OpenApi/src/Schemas/OpenApiJsonSchema.Helpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<OpenApiJsonSchema>(ref reader);
schema.AnyOf = schemas?.Select(s => s.Schema as IOpenApiSchema).ToList();
var anyOfSchemas = ReadList<OpenApiJsonSchema>(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<OpenApiJsonSchema>(ref reader);
schema.OneOf = oneOfSchemas?.Select(s => s.Schema as IOpenApiSchema).ToList();
break;
case OpenApiSchemaKeywords.DiscriminatorKeyword:
reader.Read();
Expand Down
1 change: 1 addition & 0 deletions src/OpenApi/src/Schemas/OpenApiSchemaKeywords.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
6 changes: 6 additions & 0 deletions src/OpenApi/src/Services/OpenApiDocumentService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -720,6 +720,12 @@ private async Task<OpenApiRequestBody> 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
Expand Down
4 changes: 3 additions & 1 deletion src/OpenApi/src/Services/OpenApiGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -447,7 +447,9 @@ private List<IOpenApiParameter> 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);
}
Expand Down
99 changes: 97 additions & 2 deletions src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<T>() 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<JsonNode> values)
{
var array = new JsonArray();

foreach (var value in values)
{
array.Add(value);
}

return array;
}
}

internal async Task<OpenApiSchema> GetOrCreateUnresolvedSchemaAsync(OpenApiDocument? document, Type type, IServiceProvider scopedServiceProvider, IOpenApiSchemaTransformer[] schemaTransformers, ApiParameterDescription? parameterDescription = null, CancellationToken cancellationToken = default)
{
var key = parameterDescription?.ParameterDescriptor is IParameterInfoParameterDescriptor parameterInfoDescription
Expand Down Expand Up @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<Todo>), "JsonPatchDocument"],
[typeof(Stream), "Stream"],
[typeof(PipeReader), "PipeReader"],
[typeof(Results<Ok<TodoWithDueDate>, Ok<Todo>>), "ResultsOfOkOfTodoWithDueDateAndOkOfTodo"],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
},
Expand Down Expand Up @@ -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"
Expand Down
Loading
Loading