Skip to content

Commit 5e7fffe

Browse files
[OpenApi] Generate schema for JSON Patch
Generate an appropriate OpenAPI schema for JSON Patch endpoints.
1 parent 9b7d341 commit 5e7fffe

12 files changed

+596
-18
lines changed

src/OpenApi/src/Extensions/JsonTypeInfoExtensions.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using System.Text.Json;
77
using System.Text.Json.Serialization.Metadata;
88
using Microsoft.AspNetCore.Http;
9+
using Microsoft.AspNetCore.JsonPatch.SystemTextJson;
910

1011
namespace Microsoft.AspNetCore.OpenApi;
1112

@@ -32,6 +33,7 @@ internal static class JsonTypeInfoExtensions
3233
[typeof(string)] = "string",
3334
[typeof(IFormFile)] = "IFormFile",
3435
[typeof(IFormFileCollection)] = "IFormFileCollection",
36+
[typeof(JsonPatchDocument)] = "JsonPatchDocument",
3537
[typeof(PipeReader)] = "PipeReader",
3638
[typeof(Stream)] = "Stream"
3739
};
@@ -66,6 +68,13 @@ internal static class JsonTypeInfoExtensions
6668
return simpleName;
6769
}
6870

71+
// Use the same JSON Patch schema for all generic JsonPatchDocument<T> types
72+
// as otherwise we'll generate a schema per type argument which are otherwise identical.
73+
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(JsonPatchDocument<>))
74+
{
75+
return _simpleTypeToName[typeof(JsonPatchDocument)];
76+
}
77+
6978
// Although arrays are enumerable types they are not encoded correctly
7079
// with JsonTypeInfoKind.Enumerable so we handle the Enumerable type
7180
// case here.

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,14 @@
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" />
20+
<Reference Include="Microsoft.AspNetCore.JsonPatch.SystemTextJson" />
2221
<Reference Include="Microsoft.AspNetCore.Mvc.ApiExplorer" />
23-
<Reference Include="Microsoft.AspNetCore" />
22+
<Reference Include="Microsoft.AspNetCore.Mvc.Core" />
23+
<Reference Include="Microsoft.AspNetCore.Routing" />
24+
<Reference Include="Microsoft.OpenApi" />
2425
</ItemGroup>
2526

2627
<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: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
using Microsoft.AspNetCore.Http;
1818
using Microsoft.AspNetCore.Http.Extensions;
1919
using Microsoft.AspNetCore.Http.Metadata;
20+
using Microsoft.AspNetCore.JsonPatch.SystemTextJson;
2021
using Microsoft.AspNetCore.Mvc;
2122
using Microsoft.AspNetCore.Mvc.ApiExplorer;
2223
using Microsoft.AspNetCore.Mvc.Infrastructure;
@@ -720,6 +721,12 @@ private async Task<OpenApiRequestBody> GetJsonRequestBody(
720721
// for stream-based parameter types.
721722
supportedRequestFormats = [new ApiRequestFormat { MediaType = "application/octet-stream" }];
722723
}
724+
else if (bodyParameter.Type == typeof(JsonPatchDocument) || (bodyParameter.Type.IsGenericType && bodyParameter.Type.GetGenericTypeDefinition() == typeof(JsonPatchDocument<>)))
725+
{
726+
// Assume "application/json-patch+json" as the default media type
727+
// for JSON Patch documents.
728+
supportedRequestFormats = [new ApiRequestFormat { MediaType = "application/json-patch+json" }];
729+
}
723730
else
724731
{
725732
// Assume "application/json" as the default media type

src/OpenApi/src/Services/OpenApiGenerator.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
using System.Security.Claims;
1010
using Microsoft.AspNetCore.Http;
1111
using Microsoft.AspNetCore.Http.Metadata;
12+
using Microsoft.AspNetCore.JsonPatch.SystemTextJson;
1213
using Microsoft.AspNetCore.Mvc;
1314
using Microsoft.AspNetCore.Mvc.ApiExplorer;
1415
using Microsoft.AspNetCore.Mvc.Formatters;
@@ -447,7 +448,10 @@ private List<IOpenApiParameter> GetOpenApiParameters(MethodInfo methodInfo, Rout
447448
return (false, ParameterLocation.Query, null);
448449
}
449450
}
450-
else if (parameter.ParameterType == typeof(IFormFile) || parameter.ParameterType == typeof(IFormFileCollection))
451+
else if (parameter.ParameterType == typeof(IFormFile) ||
452+
parameter.ParameterType == typeof(IFormFileCollection) ||
453+
parameter.ParameterType == typeof(JsonPatchDocument) ||
454+
parameter.ParameterType == typeof(JsonPatchDocument<>))
451455
{
452456
return (true, null, null);
453457
}

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

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
using System.Text.Json.Serialization.Metadata;
1414
using Microsoft.AspNetCore.Http;
1515
using Microsoft.AspNetCore.Http.Json;
16+
using Microsoft.AspNetCore.JsonPatch.SystemTextJson;
1617
using Microsoft.AspNetCore.Mvc.ApiExplorer;
1718
using Microsoft.AspNetCore.Mvc.Infrastructure;
1819
using Microsoft.Extensions.DependencyInjection;
@@ -82,6 +83,10 @@ internal sealed class OpenApiSchemaService(
8283
}
8384
};
8485
}
86+
else if (type == typeof(JsonPatchDocument) || (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(JsonPatchDocument<>)))
87+
{
88+
schema = CreateSchemaForJsonPatch();
89+
}
8590
// STJ uses `true` in place of an empty object to represent a schema that matches
8691
// anything (like the `object` type) or types with user-defined converters. We override
8792
// this default behavior here to match the format expected in OpenAPI v3.
@@ -117,6 +122,95 @@ internal sealed class OpenApiSchemaService(
117122
}
118123
};
119124

125+
private static JsonObject CreateSchemaForJsonPatch()
126+
{
127+
var addReplaceTest = new JsonObject()
128+
{
129+
[OpenApiSchemaKeywords.TypeKeyword] = "object",
130+
[OpenApiSchemaKeywords.AdditionalPropertiesKeyword] = false,
131+
[OpenApiSchemaKeywords.RequiredKeyword] = JsonArray(["op", "path", "value"]),
132+
[OpenApiSchemaKeywords.PropertiesKeyword] = new JsonObject
133+
{
134+
["op"] = new JsonObject()
135+
{
136+
[OpenApiSchemaKeywords.TypeKeyword] = "string",
137+
[OpenApiSchemaKeywords.EnumKeyword] = JsonArray(["add", "replace", "test"]),
138+
},
139+
["path"] = new JsonObject()
140+
{
141+
[OpenApiSchemaKeywords.TypeKeyword] = "string"
142+
},
143+
["value"] = new JsonObject()
144+
}
145+
};
146+
147+
var moveCopy = new JsonObject()
148+
{
149+
[OpenApiSchemaKeywords.TypeKeyword] = "object",
150+
[OpenApiSchemaKeywords.AdditionalPropertiesKeyword] = false,
151+
[OpenApiSchemaKeywords.RequiredKeyword] = JsonArray(["op", "path", "from"]),
152+
[OpenApiSchemaKeywords.PropertiesKeyword] = new JsonObject
153+
{
154+
["op"] = new JsonObject()
155+
{
156+
[OpenApiSchemaKeywords.TypeKeyword] = "string",
157+
[OpenApiSchemaKeywords.EnumKeyword] = JsonArray(["move", "copy"]),
158+
},
159+
["path"] = new JsonObject()
160+
{
161+
[OpenApiSchemaKeywords.TypeKeyword] = "string"
162+
},
163+
["from"] = new JsonObject()
164+
{
165+
[OpenApiSchemaKeywords.TypeKeyword] = "string"
166+
},
167+
}
168+
};
169+
170+
var remove = new JsonObject()
171+
{
172+
[OpenApiSchemaKeywords.TypeKeyword] = "object",
173+
[OpenApiSchemaKeywords.AdditionalPropertiesKeyword] = false,
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"],

src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -539,7 +539,7 @@
539539
"content": {
540540
"application/json-patch+json": {
541541
"schema": {
542-
"$ref": "#/components/schemas/JsonPatchDocumentOfParentObject"
542+
"$ref": "#/components/schemas/JsonPatchDocument"
543543
}
544544
}
545545
},
@@ -643,8 +643,76 @@
643643
}
644644
}
645645
},
646-
"JsonPatchDocument": { },
647-
"JsonPatchDocumentOfParentObject": { },
646+
"JsonPatchDocument": {
647+
"type": "array",
648+
"items": {
649+
"type": "object",
650+
"oneOf": [
651+
{
652+
"required": [
653+
"op",
654+
"path",
655+
"value"
656+
],
657+
"type": "object",
658+
"properties": {
659+
"op": {
660+
"enum": [
661+
"add",
662+
"replace",
663+
"test"
664+
],
665+
"type": "string"
666+
},
667+
"path": {
668+
"type": "string"
669+
},
670+
"value": { }
671+
},
672+
"additionalProperties": false
673+
},
674+
{
675+
"required": [
676+
"op",
677+
"path",
678+
"from"
679+
],
680+
"type": "object",
681+
"properties": {
682+
"op": {
683+
"enum": [
684+
"move",
685+
"copy"
686+
],
687+
"type": "string"
688+
},
689+
"path": {
690+
"type": "string"
691+
},
692+
"from": {
693+
"type": "string"
694+
}
695+
},
696+
"additionalProperties": false
697+
},
698+
{
699+
"type": "object",
700+
"properties": {
701+
"op": {
702+
"enum": [
703+
"remove"
704+
],
705+
"type": "string"
706+
},
707+
"path": {
708+
"type": "string"
709+
}
710+
},
711+
"additionalProperties": false
712+
}
713+
]
714+
}
715+
},
648716
"LocationContainer": {
649717
"required": [
650718
"location"

0 commit comments

Comments
 (0)