Skip to content

Commit d84aec3

Browse files
[OpenApi] Generate schema for JSON Patch endpoints (#63052)
* [OpenApi] Add non-generic JSON patch Update the sample to include a non-generic JSON patch endpoint. * [OpenApi] Generate schema for JSON Patch Generate an appropriate OpenAPI schema for JSON Patch endpoints. * Fix condition Leftovers from before tests were added. * Handle derived types - Handle custom types derived from `JsonPatchDocument` or `JsonPatchDocument<T>`. - Add extension method to remove duplicated type checks. * [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. * Update type check - Check for a generic type before type name check. - Replace `StartsWith()` with `==`. * Add required for JSON Patch remove Add `required` members to the OpenAPI schema for a JSON Patch remove operation.
1 parent 52ea667 commit d84aec3

14 files changed

+772
-16
lines changed

src/OpenApi/sample/Endpoints/MapSchemasEndpoints.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ public static IEndpointRouteBuilder MapSchemasEndpoints(this IEndpointRouteBuild
3737
schemas.MapPost("/___location", (LocationContainer ___location) => { });
3838
schemas.MapPost("/parent", (ParentObject parent) => Results.Ok(parent));
3939
schemas.MapPost("/child", (ChildObject child) => Results.Ok(child));
40-
schemas.MapPatch("/json-patch", (JsonPatchDocument<ParentObject> patchDoc) => Results.NoContent());
40+
schemas.MapPatch("/json-patch", (JsonPatchDocument patchDoc) => Results.NoContent());
41+
schemas.MapPatch("/json-patch-generic", (JsonPatchDocument<ParentObject> patchDoc) => Results.NoContent());
4142

4243
return endpointRouteBuilder;
4344
}

src/OpenApi/src/Extensions/JsonTypeInfoExtensions.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,14 @@ internal static class JsonTypeInfoExtensions
6666
return simpleName;
6767
}
6868

69+
// Use the same JSON Patch schema for all JSON Patch document types (JsonPatchDocument,
70+
// JsonPatchDocument<T>, derived types, etc.) as otherwise we'll generate a schema
71+
// per unique type which are otherwise identical to each other.
72+
if (type.IsJsonPatchDocument())
73+
{
74+
return "JsonPatchDocument";
75+
}
76+
6977
// Although arrays are enumerable types they are not encoded correctly
7078
// with JsonTypeInfoKind.Enumerable so we handle the Enumerable type
7179
// case here.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace Microsoft.AspNetCore.OpenApi;
5+
6+
internal static class TypeExtensions
7+
{
8+
private const string JsonPatchDocumentNamespace = "Microsoft.AspNetCore.JsonPatch.SystemTextJson";
9+
private const string JsonPatchDocumentName = "JsonPatchDocument";
10+
private const string JsonPatchDocumentNameOfT = "JsonPatchDocument`1";
11+
12+
public static bool IsJsonPatchDocument(this Type type)
13+
{
14+
// We cannot depend on the actual runtime type as
15+
// Microsoft.AspNetCore.JsonPatch.SystemTextJson is not
16+
// AoT compatible so cannot be referenced by Microsoft.AspNetCore.OpenApi.
17+
var modelType = type;
18+
19+
while (modelType != null && modelType != typeof(object))
20+
{
21+
if (modelType.Namespace == JsonPatchDocumentNamespace &&
22+
(modelType.Name == JsonPatchDocumentName ||
23+
(modelType.IsGenericType && modelType.GenericTypeArguments.Length == 1 && modelType.Name == JsonPatchDocumentNameOfT)))
24+
{
25+
return true;
26+
}
27+
28+
modelType = modelType.BaseType;
29+
}
30+
31+
return false;
32+
}
33+
}

src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,13 @@
1414
</PropertyGroup>
1515

1616
<ItemGroup>
17-
<Reference Include="Microsoft.OpenApi" />
17+
<Reference Include="Microsoft.AspNetCore" />
1818
<Reference Include="Microsoft.AspNetCore.Http.Abstractions" />
1919
<Reference Include="Microsoft.AspNetCore.Http.Results" />
20-
<Reference Include="Microsoft.AspNetCore.Routing" />
21-
<Reference Include="Microsoft.AspNetCore.Mvc.Core" />
2220
<Reference Include="Microsoft.AspNetCore.Mvc.ApiExplorer" />
23-
<Reference Include="Microsoft.AspNetCore" />
21+
<Reference Include="Microsoft.AspNetCore.Mvc.Core" />
22+
<Reference Include="Microsoft.AspNetCore.Routing" />
23+
<Reference Include="Microsoft.OpenApi" />
2424
</ItemGroup>
2525

2626
<ItemGroup>

src/OpenApi/src/Schemas/OpenApiJsonSchema.Helpers.cs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -298,8 +298,14 @@ public static void ReadProperty(ref Utf8JsonReader reader, string propertyName,
298298
case OpenApiSchemaKeywords.AnyOfKeyword:
299299
reader.Read();
300300
schema.Type = JsonSchemaType.Object;
301-
var schemas = ReadList<OpenApiJsonSchema>(ref reader);
302-
schema.AnyOf = schemas?.Select(s => s.Schema as IOpenApiSchema).ToList();
301+
var anyOfSchemas = ReadList<OpenApiJsonSchema>(ref reader);
302+
schema.AnyOf = anyOfSchemas?.Select(s => s.Schema as IOpenApiSchema).ToList();
303+
break;
304+
case OpenApiSchemaKeywords.OneOfKeyword:
305+
reader.Read();
306+
schema.Type = JsonSchemaType.Object;
307+
var oneOfSchemas = ReadList<OpenApiJsonSchema>(ref reader);
308+
schema.OneOf = oneOfSchemas?.Select(s => s.Schema as IOpenApiSchema).ToList();
303309
break;
304310
case OpenApiSchemaKeywords.DiscriminatorKeyword:
305311
reader.Read();

src/OpenApi/src/Schemas/OpenApiSchemaKeywords.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ internal class OpenApiSchemaKeywords
1010
public const string AdditionalPropertiesKeyword = "additionalProperties";
1111
public const string RequiredKeyword = "required";
1212
public const string AnyOfKeyword = "anyOf";
13+
public const string OneOfKeyword = "oneOf";
1314
public const string EnumKeyword = "enum";
1415
public const string DefaultKeyword = "default";
1516
public const string DescriptionKeyword = "description";

src/OpenApi/src/Services/OpenApiDocumentService.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -720,6 +720,12 @@ private async Task<OpenApiRequestBody> GetJsonRequestBody(
720720
// for stream-based parameter types.
721721
supportedRequestFormats = [new ApiRequestFormat { MediaType = "application/octet-stream" }];
722722
}
723+
else if (bodyParameter.Type.IsJsonPatchDocument())
724+
{
725+
// Assume "application/json-patch+json" as the default media type
726+
// for JSON Patch documents.
727+
supportedRequestFormats = [new ApiRequestFormat { MediaType = "application/json-patch+json" }];
728+
}
723729
else
724730
{
725731
// Assume "application/json" as the default media type

src/OpenApi/src/Services/OpenApiGenerator.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -447,7 +447,9 @@ private List<IOpenApiParameter> GetOpenApiParameters(MethodInfo methodInfo, Rout
447447
return (false, ParameterLocation.Query, null);
448448
}
449449
}
450-
else if (parameter.ParameterType == typeof(IFormFile) || parameter.ParameterType == typeof(IFormFileCollection))
450+
else if (parameter.ParameterType == typeof(IFormFile) ||
451+
parameter.ParameterType == typeof(IFormFileCollection) ||
452+
parameter.ParameterType.IsJsonPatchDocument())
451453
{
452454
return (true, null, null);
453455
}

src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ internal sealed class OpenApiSchemaService(
8282
}
8383
};
8484
}
85+
else if (type.IsJsonPatchDocument())
86+
{
87+
schema = CreateSchemaForJsonPatch();
88+
}
8589
// STJ uses `true` in place of an empty object to represent a schema that matches
8690
// anything (like the `object` type) or types with user-defined converters. We override
8791
// this default behavior here to match the format expected in OpenAPI v3.
@@ -117,6 +121,96 @@ internal sealed class OpenApiSchemaService(
117121
}
118122
};
119123

124+
private static JsonObject CreateSchemaForJsonPatch()
125+
{
126+
var addReplaceTest = new JsonObject()
127+
{
128+
[OpenApiSchemaKeywords.TypeKeyword] = "object",
129+
[OpenApiSchemaKeywords.AdditionalPropertiesKeyword] = false,
130+
[OpenApiSchemaKeywords.RequiredKeyword] = JsonArray(["op", "path", "value"]),
131+
[OpenApiSchemaKeywords.PropertiesKeyword] = new JsonObject
132+
{
133+
["op"] = new JsonObject()
134+
{
135+
[OpenApiSchemaKeywords.TypeKeyword] = "string",
136+
[OpenApiSchemaKeywords.EnumKeyword] = JsonArray(["add", "replace", "test"]),
137+
},
138+
["path"] = new JsonObject()
139+
{
140+
[OpenApiSchemaKeywords.TypeKeyword] = "string"
141+
},
142+
["value"] = new JsonObject()
143+
}
144+
};
145+
146+
var moveCopy = new JsonObject()
147+
{
148+
[OpenApiSchemaKeywords.TypeKeyword] = "object",
149+
[OpenApiSchemaKeywords.AdditionalPropertiesKeyword] = false,
150+
[OpenApiSchemaKeywords.RequiredKeyword] = JsonArray(["op", "path", "from"]),
151+
[OpenApiSchemaKeywords.PropertiesKeyword] = new JsonObject
152+
{
153+
["op"] = new JsonObject()
154+
{
155+
[OpenApiSchemaKeywords.TypeKeyword] = "string",
156+
[OpenApiSchemaKeywords.EnumKeyword] = JsonArray(["move", "copy"]),
157+
},
158+
["path"] = new JsonObject()
159+
{
160+
[OpenApiSchemaKeywords.TypeKeyword] = "string"
161+
},
162+
["from"] = new JsonObject()
163+
{
164+
[OpenApiSchemaKeywords.TypeKeyword] = "string"
165+
},
166+
}
167+
};
168+
169+
var remove = new JsonObject()
170+
{
171+
[OpenApiSchemaKeywords.TypeKeyword] = "object",
172+
[OpenApiSchemaKeywords.AdditionalPropertiesKeyword] = false,
173+
[OpenApiSchemaKeywords.RequiredKeyword] = JsonArray(["op", "path"]),
174+
[OpenApiSchemaKeywords.PropertiesKeyword] = new JsonObject
175+
{
176+
["op"] = new JsonObject()
177+
{
178+
[OpenApiSchemaKeywords.TypeKeyword] = "string",
179+
[OpenApiSchemaKeywords.EnumKeyword] = JsonArray(["remove"])
180+
},
181+
["path"] = new JsonObject()
182+
{
183+
[OpenApiSchemaKeywords.TypeKeyword] = "string"
184+
},
185+
}
186+
};
187+
188+
return new JsonObject
189+
{
190+
[OpenApiConstants.SchemaId] = "JsonPatchDocument",
191+
[OpenApiSchemaKeywords.TypeKeyword] = "array",
192+
[OpenApiSchemaKeywords.ItemsKeyword] = new JsonObject
193+
{
194+
[OpenApiSchemaKeywords.OneOfKeyword] = JsonArray([addReplaceTest, moveCopy, remove])
195+
},
196+
};
197+
198+
// Using JsonArray inline causes the compile to pick the generic Add<T>() overload
199+
// which then generates native AoT warnings without adding a cost. To Avoid that use
200+
// this helper method that uses JsonNode to pick the native AoT compatible overload instead.
201+
static JsonArray JsonArray(ReadOnlySpan<JsonNode> values)
202+
{
203+
var array = new JsonArray();
204+
205+
foreach (var value in values)
206+
{
207+
array.Add(value);
208+
}
209+
210+
return array;
211+
}
212+
}
213+
120214
internal async Task<OpenApiSchema> GetOrCreateUnresolvedSchemaAsync(OpenApiDocument? document, Type type, IServiceProvider scopedServiceProvider, IOpenApiSchemaTransformer[] schemaTransformers, ApiParameterDescription? parameterDescription = null, CancellationToken cancellationToken = default)
121215
{
122216
var key = parameterDescription?.ParameterDescriptor is IParameterInfoParameterDescriptor parameterInfoDescription
@@ -320,9 +414,10 @@ private async Task InnerApplySchemaTransformersAsync(IOpenApiSchema inputSchema,
320414
}
321415
}
322416

323-
if (schema.Items is not null)
417+
// If the schema is an array but uses AnyOf or OneOf then ElementType is null
418+
if (schema.Items is not null && jsonTypeInfo.ElementType is not null)
324419
{
325-
var elementTypeInfo = _jsonSerializerOptions.GetTypeInfo(jsonTypeInfo.ElementType!);
420+
var elementTypeInfo = _jsonSerializerOptions.GetTypeInfo(jsonTypeInfo.ElementType);
326421
await InnerApplySchemaTransformersAsync(schema.Items, elementTypeInfo, null, context, transformer, cancellationToken);
327422
}
328423

src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Extensions/JsonTypeInfoExtensionsTests.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System.Text.Json;
66
using Microsoft.AspNetCore.Http;
77
using Microsoft.AspNetCore.Http.HttpResults;
8+
using Microsoft.AspNetCore.JsonPatch.SystemTextJson;
89
using Microsoft.AspNetCore.Mvc;
910
using Microsoft.AspNetCore.OpenApi;
1011

@@ -58,6 +59,8 @@ public class Baz
5859
[(new { Id = 1, Name = "Todo" }).GetType(), "AnonymousTypeOfintAndstring"],
5960
[typeof(IFormFile), "IFormFile"],
6061
[typeof(IFormFileCollection), "IFormFileCollection"],
62+
[typeof(JsonPatchDocument), "JsonPatchDocument"],
63+
[typeof(JsonPatchDocument<Todo>), "JsonPatchDocument"],
6164
[typeof(Stream), "Stream"],
6265
[typeof(PipeReader), "PipeReader"],
6366
[typeof(Results<Ok<TodoWithDueDate>, Ok<Todo>>), "ResultsOfOkOfTodoWithDueDateAndOkOfTodo"],

0 commit comments

Comments
 (0)