diff --git a/PTrampert.SimplePatch.OpenApi.Test/GlobalUsings.cs b/PTrampert.SimplePatch.OpenApi.Test/GlobalUsings.cs new file mode 100644 index 0000000..3244567 --- /dev/null +++ b/PTrampert.SimplePatch.OpenApi.Test/GlobalUsings.cs @@ -0,0 +1 @@ +global using NUnit.Framework; diff --git a/PTrampert.SimplePatch.OpenApi.Test/PTrampert.SimplePatch.OpenApi.Test.csproj b/PTrampert.SimplePatch.OpenApi.Test/PTrampert.SimplePatch.OpenApi.Test.csproj new file mode 100644 index 0000000..70595be --- /dev/null +++ b/PTrampert.SimplePatch.OpenApi.Test/PTrampert.SimplePatch.OpenApi.Test.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + enable + enable + + false + true + + + $(InterceptorsNamespaces);Microsoft.AspNetCore.OpenApi.Generated + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/PTrampert.SimplePatch.OpenApi.Test/PatchObjectSchemaTransformerTest.cs b/PTrampert.SimplePatch.OpenApi.Test/PatchObjectSchemaTransformerTest.cs new file mode 100644 index 0000000..da30057 --- /dev/null +++ b/PTrampert.SimplePatch.OpenApi.Test/PatchObjectSchemaTransformerTest.cs @@ -0,0 +1,136 @@ +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi; +using PTrampert.SimplePatch.OpenApi.Test.TestObjects; + +namespace PTrampert.SimplePatch.OpenApi.Test; + +public class PatchObjectSchemaTransformerTest +{ + private const string DefaultPatchSchemaId = "IPatchObjectOfPersonTestModel"; + + [Test] + public async Task PatchSchema_DescribesThePatchedModelsProperties() + { + var (patchSchema, _) = await GeneratePatchSchemaAsync(); + + Assert.That(patchSchema.Properties?.Keys, + Is.EquivalentTo(new[] { "name", "dateOfBirth", "email", "nick_name" })); + } + + [Test] + public async Task PatchSchema_KeepsTheValidationConstraintsOfThePatchedModel() + { + var (patchSchema, _) = await GeneratePatchSchemaAsync(); + + var name = patchSchema.Properties!["name"]; + Assert.Multiple((Action)(() => + { + Assert.That(name.MaxLength, Is.EqualTo(255)); + Assert.That(name.MinLength, Is.EqualTo(3)); + })); + } + + [Test] + public async Task PatchSchema_MakesEveryPropertyOptional() + { + var (patchSchema, document) = await GeneratePatchSchemaAsync(); + + Assert.Multiple((Action)(() => + { + Assert.That(patchSchema.Required, Is.Null.Or.Empty); + Assert.That(document.Components!.Schemas![nameof(PersonTestModel)].Required, Does.Contain("name"), + "The patched model's own schema must keep its required properties — only the patch body is partial."); + })); + } + + [Test] + public async Task PatchSchema_OmitsPropertiesThePatchObjectCannotSet() + { + var (patchSchema, _) = await GeneratePatchSchemaAsync(); + + Assert.Multiple((Action)(() => + { + Assert.That(patchSchema.Properties, Does.Not.ContainKey("display"), + "Get-only properties are not patchable."); + Assert.That(patchSchema.Properties, Does.Not.ContainKey("secret"), + "[JsonIgnore] properties are not patchable."); + })); + } + + [Test] + public async Task PatchSchema_DescribesItselfAsAPartialUpdate() + { + var (patchSchema, _) = await GeneratePatchSchemaAsync(); + + Assert.That(patchSchema.Description, + Is.EqualTo("Partial update of PersonTestModel. Omitted properties are left unchanged.")); + } + + [Test] + public async Task PatchSchema_UsesTheSuppliedExample() + { + var (patchSchema, _) = await GeneratePatchSchemaAsync(options => + options.Example = _ => new JsonObject { ["name"] = "New Name" }); + + Assert.That(patchSchema.Example?.ToJsonString(), Is.EqualTo("""{"name":"New Name"}""")); + } + + [Test] + public async Task PatchSchema_IsNamedByTheSchemaIdOptionWhenSupplied() + { + var (_, document) = await GeneratePatchSchemaAsync( + options => options.SchemaId = type => $"{type.Name}Patch", + patchSchemaId: "PersonTestModelPatch"); + + Assert.Multiple((Action)(() => + { + Assert.That(document.Components!.Schemas!, Does.ContainKey("PersonTestModelPatch")); + Assert.That(document.Components!.Schemas!, Does.Not.ContainKey(DefaultPatchSchemaId)); + Assert.That(document.Components!.Schemas!, Does.ContainKey(nameof(PersonTestModel)), + "The schema id override must only apply to patch bodies."); + })); + } + + [Test] + public async Task PutBody_IsLeftAlone() + { + var (_, document) = await GeneratePatchSchemaAsync(); + + var put = document.Paths!["/people/{id}"].Operations![HttpMethod.Put]; + var putBody = put.RequestBody!.Content!["application/json"].Schema!; + + Assert.That(putBody, Is.InstanceOf() + .With.Property(nameof(OpenApiSchemaReference.Reference)).Property("Id").EqualTo(nameof(PersonTestModel))); + } + + private static async Task<(IOpenApiSchema PatchSchema, OpenApiDocument Document)> GeneratePatchSchemaAsync( + Action? configure = null, + string patchSchemaId = DefaultPatchSchemaId) + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.AddSimplePatchConverters()); + builder.Services.AddOpenApi(options => options.AddSimplePatchSchemas(configure)); + + await using var app = builder.Build(); + app.MapPatch("/people/{id:int}", (int id, IPatchObject patch) => Results.Ok()); + app.MapPut("/people/{id:int}", (int id, PersonTestModel person) => Results.Ok()); + + // Endpoints only reach the document generator once the app has started. + await app.StartAsync(); + + // AddOpenApi registers one provider per document, keyed by document name. + var document = await app.Services + .GetRequiredKeyedService("v1") + .GetOpenApiDocumentAsync(); + + await app.StopAsync(); + + return (document.Components!.Schemas![patchSchemaId], document); + } +} diff --git a/PTrampert.SimplePatch.OpenApi.Test/TestObjects/PersonTestModel.cs b/PTrampert.SimplePatch.OpenApi.Test/TestObjects/PersonTestModel.cs new file mode 100644 index 0000000..df88de4 --- /dev/null +++ b/PTrampert.SimplePatch.OpenApi.Test/TestObjects/PersonTestModel.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace PTrampert.SimplePatch.OpenApi.Test.TestObjects; + +public record PersonTestModel +{ + [Required] + [StringLength(255, MinimumLength = 3)] + public required string Name { get; init; } + + public DateTime DateOfBirth { get; init; } + + [EmailAddress] + public string? Email { get; init; } + + [JsonPropertyName("nick_name")] + public string? NickName { get; init; } + + [JsonIgnore] + public string? Secret { get; init; } + + // Get-only, so the generated patch class cannot set it. The source model's schema still + // describes it, marked readOnly. + public string Display => Name; +} diff --git a/PTrampert.SimplePatch.OpenApi/PTrampert.SimplePatch.OpenApi.csproj b/PTrampert.SimplePatch.OpenApi/PTrampert.SimplePatch.OpenApi.csproj new file mode 100644 index 0000000..9cc227b --- /dev/null +++ b/PTrampert.SimplePatch.OpenApi/PTrampert.SimplePatch.OpenApi.csproj @@ -0,0 +1,38 @@ + + + + + net10.0 + enable + enable + true + pjt15253 + Microsoft.AspNetCore.OpenApi integration for PTrampert.SimplePatch. Documents routes taking an IPatchObject<T> with the patched model's schema instead of an empty object. + README.md + MIT + https://github.com/PaulTrampert/PTrampert.Optionals + https://github.com/PaulTrampert/PTrampert.Optionals/releases + https://github.com/PaulTrampert/PTrampert.Optionals.git + true + snupkg + + + + + + + + + + + + + + + + + diff --git a/PTrampert.SimplePatch.OpenApi/PatchObjectSchemaTransformer.cs b/PTrampert.SimplePatch.OpenApi/PatchObjectSchemaTransformer.cs new file mode 100644 index 0000000..3017ea3 --- /dev/null +++ b/PTrampert.SimplePatch.OpenApi/PatchObjectSchemaTransformer.cs @@ -0,0 +1,59 @@ +using System.Collections.Concurrent; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; + +namespace PTrampert.SimplePatch.OpenApi; + +/// +/// Replaces the empty schema the built-in OpenAPI generator produces for an +/// request body with the patched model's schema, with every +/// property optional. +/// +/// +/// Register it with AddSimplePatchSchemas rather than directly, so that the schema-id +/// option is wired up too. +/// +/// The consumer's adjustments. +public class PatchObjectSchemaTransformer(SimplePatchSchemaOptions options) : IOpenApiSchemaTransformer +{ + private static readonly ConcurrentDictionary> PatchablePropertyNamesBySourceType = new(); + + /// + public async Task TransformAsync( + OpenApiSchema schema, + OpenApiSchemaTransformerContext context, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(context); + + var type = context.JsonTypeInfo.Type; + + // Only the interface is documented; the generated patch class never reaches the document. + if (!type.IsInterface || !type.TryGetPatchSourceType(out var sourceType)) + { + return; + } + + var source = await context.GetOrCreateSchemaAsync(sourceType, cancellationToken: cancellationToken); + + PatchSchemaTransform.Apply( + schema, + source, + sourceType, + GetPatchablePropertyNames(sourceType, context), + options); + } + + private static HashSet GetPatchablePropertyNames(Type sourceType, OpenApiSchemaTransformerContext context) => + PatchablePropertyNamesBySourceType.GetOrAdd(sourceType, _ => + { + // JsonTypeInfo.Options is the same JsonSerializerOptions the document generator is + // using, so the names here match the ones in the source model's schema exactly — + // naming policy, [JsonPropertyName] and all. + var patchType = PatchClassBuilder.Instance.GetPatchClassFor(sourceType); + return context.JsonTypeInfo.Options + .GetTypeInfo(patchType).Properties + .Select(property => property.Name) + .ToHashSet(StringComparer.Ordinal); + }); +} diff --git a/PTrampert.SimplePatch.OpenApi/SimplePatchOpenApiOptionsExtensions.cs b/PTrampert.SimplePatch.OpenApi/SimplePatchOpenApiOptionsExtensions.cs new file mode 100644 index 0000000..257d7f4 --- /dev/null +++ b/PTrampert.SimplePatch.OpenApi/SimplePatchOpenApiOptionsExtensions.cs @@ -0,0 +1,48 @@ +using Microsoft.AspNetCore.OpenApi; +using PTrampert.SimplePatch; +using PTrampert.SimplePatch.OpenApi; + +// Matches where AddOpenApi lives, so this is in scope wherever it is. +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Adds SimplePatch's OpenAPI support to the built-in OpenAPI document generator. +/// +public static class SimplePatchOpenApiOptionsExtensions +{ + /// + /// Documents routes taking an request body with the patched + /// model's schema, with every property optional, instead of the empty object the generator + /// produces for the interface. + /// + /// + /// + /// builder.Services.AddOpenApi(options => options.AddSimplePatchSchemas()); + /// + /// + /// The OpenAPI options to add the transformer to. + /// Optional adjustments to the generated schema. + /// , for chaining. + public static OpenApiOptions AddSimplePatchSchemas( + this OpenApiOptions options, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(options); + + var schemaOptions = new SimplePatchSchemaOptions(); + configure?.Invoke(schemaOptions); + + options.AddSchemaTransformer(new PatchObjectSchemaTransformer(schemaOptions)); + + if (schemaOptions.SchemaId is { } schemaId) + { + var inner = options.CreateSchemaReferenceId; + options.CreateSchemaReferenceId = jsonTypeInfo => + jsonTypeInfo.Type.IsInterface && jsonTypeInfo.Type.TryGetPatchSourceType(out var sourceType) + ? schemaId(sourceType) ?? inner(jsonTypeInfo) + : inner(jsonTypeInfo); + } + + return options; + } +} diff --git a/PTrampert.SimplePatch.Sample/PTrampert.SimplePatch.Sample.csproj b/PTrampert.SimplePatch.Sample/PTrampert.SimplePatch.Sample.csproj index 135a196..217c5e7 100644 --- a/PTrampert.SimplePatch.Sample/PTrampert.SimplePatch.Sample.csproj +++ b/PTrampert.SimplePatch.Sample/PTrampert.SimplePatch.Sample.csproj @@ -12,6 +12,7 @@ + diff --git a/PTrampert.SimplePatch.Sample/Program.cs b/PTrampert.SimplePatch.Sample/Program.cs index d365005..47b708a 100644 --- a/PTrampert.SimplePatch.Sample/Program.cs +++ b/PTrampert.SimplePatch.Sample/Program.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Nodes; using PTrampert.SimplePatch; var builder = WebApplication.CreateBuilder(args); @@ -12,7 +13,17 @@ }); // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); +builder.Services.AddSwaggerGen(opts => +{ + // Document IPatchObject request bodies with the patched model's schema. + opts.AddSimplePatchSchemas(patch => + { + // Swagger UI builds its example from the schema's properties and ignores "required", + // so without this the PATCH example lists every property — which reads as "send all of + // these", and is what "Try it out" would submit. + patch.Example = _ => new JsonObject { ["name"] = "New Name" }; + }); +}); var app = builder.Build(); diff --git a/PTrampert.SimplePatch.Schema/PTrampert.SimplePatch.Schema.csproj b/PTrampert.SimplePatch.Schema/PTrampert.SimplePatch.Schema.csproj new file mode 100644 index 0000000..a363095 --- /dev/null +++ b/PTrampert.SimplePatch.Schema/PTrampert.SimplePatch.Schema.csproj @@ -0,0 +1,36 @@ + + + + + net8.0 + enable + enable + true + pjt15253 + Shared OpenAPI schema generation for PTrampert.SimplePatch. Referenced by the Swashbuckle and Microsoft.AspNetCore.OpenApi integration packages; not usually installed directly. + README.md + MIT + https://github.com/PaulTrampert/PTrampert.Optionals + https://github.com/PaulTrampert/PTrampert.Optionals/releases + https://github.com/PaulTrampert/PTrampert.Optionals.git + true + snupkg + + + + + + + + + + + + + + + + diff --git a/PTrampert.SimplePatch.Schema/PatchSchemaTransform.cs b/PTrampert.SimplePatch.Schema/PatchSchemaTransform.cs new file mode 100644 index 0000000..1fd38fc --- /dev/null +++ b/PTrampert.SimplePatch.Schema/PatchSchemaTransform.cs @@ -0,0 +1,63 @@ +using Microsoft.OpenApi; + +namespace PTrampert.SimplePatch; + +/// +/// The patch-schema transform, shared by the Swashbuckle and built-in OpenAPI integration packages +/// — which reference this one — so that the two cannot drift. +/// +/// +/// The patch contract for a model is the model's own contract with optional members, so the +/// schema is derived from the source model's schema rather than from the class +/// emits. That keeps validation constraints, custom converters, +/// property naming and any tuning the application already applied to its write model, and makes +/// it impossible for the two contracts to describe different shapes. +/// +public static class PatchSchemaTransform +{ + /// + /// Rewrites — the near-empty schema an OpenAPI generator produces for + /// the interface — into the patch contract for + /// . + /// + /// The schema to rewrite, in place. + /// The source model's generated schema. + /// The patched type. + /// + /// JSON names of the properties the generated patch class can actually set. The source schema + /// also describes members the patch class omits — get-only properties, which it documents as + /// readOnly — and those must not appear in a patch body. + /// + /// The consumer's adjustments. + public static void Apply( + OpenApiSchema target, + IOpenApiSchema source, + Type sourceType, + ICollection patchablePropertyNames, + SimplePatchSchemaOptions options) + { + target.Type = source.Type; + target.AdditionalPropertiesAllowed = source.AdditionalPropertiesAllowed; + target.Properties = source.Properties? + .Where(property => patchablePropertyNames.Contains(property.Key)) + .ToDictionary(property => property.Key, property => property.Value); + + // Dropping required is what makes this a partial update: every property becomes optional, + // while each property's own schema — including its nullability — is left alone, so sending + // null for a non-nullable member stays invalid. When the consumer turns that off they mean + // "require what the model requires", so the source's list is carried over instead. + target.Required = options.ClearRequired || source.Required is null + ? null + : new HashSet(source.Required.Where(patchablePropertyNames.Contains), StringComparer.Ordinal); + + if (options.DescriptionFormat is { } descriptionFormat) + { + target.Description = string.Format(descriptionFormat, sourceType.Name); + } + + if (options.Example?.Invoke(sourceType) is { } example) + { + target.Example = example; + } + } +} diff --git a/PTrampert.SimplePatch.Schema/SimplePatchSchemaOptions.cs b/PTrampert.SimplePatch.Schema/SimplePatchSchemaOptions.cs new file mode 100644 index 0000000..453ffb2 --- /dev/null +++ b/PTrampert.SimplePatch.Schema/SimplePatchSchemaOptions.cs @@ -0,0 +1,50 @@ +using System.Text.Json.Nodes; + +namespace PTrampert.SimplePatch; + +/// +/// Controls how the OpenAPI integration packages describe an +/// request body. The defaults produce the source model's schema with PATCH semantics applied; +/// every property here is an opt-in adjustment on top of that. +/// +/// +/// This type lives in the core package so that the Swashbuckle and built-in OpenAPI +/// integrations configure identically and can be referenced side by side. +/// +public class SimplePatchSchemaOptions +{ + /// + /// Names the schema component for a patch body, given the patched type. Return null to keep + /// the id the OpenAPI generator would pick — PersonWriteModelIPatchObject under + /// Swashbuckle, IPatchObjectOfPersonWriteModel under the built-in generator. + /// Set to t => t.Name + "Patch" for the friendlier PersonWriteModelPatch. + /// Default null. + /// + public Func? SchemaId { get; set; } + + /// + /// Format string for the patch schema's description, where {0} is the patched type's + /// name. Set to null to leave whatever description the source model's schema carries. + /// + public string? DescriptionFormat { get; set; } = + "Partial update of {0}. Omitted properties are left unchanged."; + + /// + /// Clear required on the patch schema. This is what makes the body a partial update, + /// so leave it on unless you have a reason not to. Default true. + /// + public bool ClearRequired { get; set; } = true; + + /// + /// Supplies the example body for a patch schema, given the patched type. Return null — the + /// default — to let the UI synthesize one. + /// + /// + /// Swagger UI builds its example from properties and ignores required, so a + /// synthesized PATCH example lists every property, which reads as "send all of these" and is + /// what "Try it out" will submit. Supplying an example of one or two properties demonstrates + /// the partial-update semantics instead. No default is provided because any property this + /// library picked for you would be an arbitrary choice. + /// + public Func? Example { get; set; } +} diff --git a/PTrampert.SimplePatch.Swashbuckle.Test/GlobalUsings.cs b/PTrampert.SimplePatch.Swashbuckle.Test/GlobalUsings.cs new file mode 100644 index 0000000..3244567 --- /dev/null +++ b/PTrampert.SimplePatch.Swashbuckle.Test/GlobalUsings.cs @@ -0,0 +1 @@ +global using NUnit.Framework; diff --git a/PTrampert.SimplePatch.Swashbuckle.Test/PTrampert.SimplePatch.Swashbuckle.Test.csproj b/PTrampert.SimplePatch.Swashbuckle.Test/PTrampert.SimplePatch.Swashbuckle.Test.csproj new file mode 100644 index 0000000..5f5568a --- /dev/null +++ b/PTrampert.SimplePatch.Swashbuckle.Test/PTrampert.SimplePatch.Swashbuckle.Test.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/PTrampert.SimplePatch.Swashbuckle.Test/PatchObjectSchemaFilterTest.cs b/PTrampert.SimplePatch.Swashbuckle.Test/PatchObjectSchemaFilterTest.cs new file mode 100644 index 0000000..d0845d4 --- /dev/null +++ b/PTrampert.SimplePatch.Swashbuckle.Test/PatchObjectSchemaFilterTest.cs @@ -0,0 +1,141 @@ +using System.Text.Json.Nodes; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi; +using PTrampert.SimplePatch.Swashbuckle.Test.TestObjects; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace PTrampert.SimplePatch.Swashbuckle.Test; + +public class PatchObjectSchemaFilterTest +{ + private const string DefaultPatchSchemaId = "PersonTestModelIPatchObject"; + + [Test] + public void PatchSchema_DescribesThePatchedModelsProperties() + { + var (patchSchema, _) = GeneratePatchSchema(); + + Assert.That(patchSchema.Properties?.Keys, + Is.EquivalentTo(new[] { "name", "dateOfBirth", "email", "nick_name" })); + } + + [Test] + public void PatchSchema_KeepsTheValidationConstraintsOfThePatchedModel() + { + var (patchSchema, _) = GeneratePatchSchema(); + + var name = patchSchema.Properties!["name"]; + Assert.Multiple((Action)(() => + { + Assert.That(name.MaxLength, Is.EqualTo(255)); + Assert.That(name.MinLength, Is.EqualTo(3)); + Assert.That(patchSchema.Properties!["email"].Format, Is.EqualTo("email")); + })); + } + + [Test] + public void PatchSchema_MakesEveryPropertyOptional() + { + var (patchSchema, repository) = GeneratePatchSchema(); + + Assert.Multiple((Action)(() => + { + Assert.That(patchSchema.Required, Is.Null.Or.Empty); + Assert.That(repository.Schemas[nameof(PersonTestModel)].Required, Does.Contain("name"), + "The patched model's own schema must keep its required properties — only the patch body is partial."); + })); + } + + [Test] + public void PatchSchema_OmitsPropertiesThePatchObjectCannotSet() + { + var (patchSchema, repository) = GeneratePatchSchema(); + + Assert.Multiple((Action)(() => + { + Assert.That(patchSchema.Properties, Does.Not.ContainKey("display"), + "Get-only properties are not patchable."); + Assert.That(repository.Schemas[nameof(PersonTestModel)].Properties, Does.ContainKey("display"), + "Guard: the patched model's schema does describe the get-only property."); + Assert.That(patchSchema.Properties, Does.Not.ContainKey("secret"), + "[JsonIgnore] properties are not patchable."); + })); + } + + [Test] + public void PatchSchema_DescribesItselfAsAPartialUpdate() + { + var (patchSchema, _) = GeneratePatchSchema(); + + Assert.That(patchSchema.Description, + Is.EqualTo("Partial update of PersonTestModel. Omitted properties are left unchanged.")); + } + + [Test] + public void PatchSchema_KeepsTheGeneratedDescriptionWhenTheFormatIsCleared() + { + var (patchSchema, _) = GeneratePatchSchema(options => options.DescriptionFormat = null); + + Assert.That(patchSchema.Description, Is.Null); + } + + [Test] + public void PatchSchema_KeepsRequiredWhenClearRequiredIsOff() + { + var (patchSchema, _) = GeneratePatchSchema(options => options.ClearRequired = false); + + Assert.That(patchSchema.Required, Does.Contain("name")); + } + + [Test] + public void PatchSchema_UsesTheSuppliedExample() + { + var (patchSchema, _) = GeneratePatchSchema(options => + options.Example = _ => new JsonObject { ["name"] = "New Name" }); + + Assert.That(patchSchema.Example?.ToJsonString(), Is.EqualTo("""{"name":"New Name"}""")); + } + + [Test] + public void PatchSchema_IsNamedByTheSchemaIdOptionWhenSupplied() + { + var (_, repository) = GeneratePatchSchema(options => options.SchemaId = type => $"{type.Name}Patch"); + + Assert.Multiple((Action)(() => + { + Assert.That(repository.Schemas, Does.ContainKey("PersonTestModelPatch")); + Assert.That(repository.Schemas, Does.Not.ContainKey(DefaultPatchSchemaId)); + Assert.That(repository.Schemas, Does.ContainKey(nameof(PersonTestModel)), + "The schema id override must only apply to patch bodies."); + })); + } + + [Test] + public void PatchSchema_LeavesTheDocumentFreeOfOptionalSchemas() + { + var (_, repository) = GeneratePatchSchema(); + + Assert.That(repository.Schemas.Keys.Where(id => id.Contains("Optional", StringComparison.Ordinal)), + Is.Empty, + "Resolving the patchable property names must not leak Optional components into the document."); + } + + private static (IOpenApiSchema PatchSchema, SchemaRepository Repository) GeneratePatchSchema( + Action? configure = null, + string patchSchemaId = DefaultPatchSchemaId) + { + var services = new ServiceCollection(); + services.AddSwaggerGen(options => options.AddSimplePatchSchemas(configure)); + + using var provider = services.BuildServiceProvider(); + var generator = provider.GetRequiredService(); + + var repository = new SchemaRepository(); + generator.GenerateSchema(typeof(IPatchObject), repository); + + var id = repository.Schemas.ContainsKey(patchSchemaId) + ? patchSchemaId + : repository.Schemas.Keys.Single(key => key != nameof(PersonTestModel)); + return (repository.Schemas[id], repository); + } +} diff --git a/PTrampert.SimplePatch.Swashbuckle.Test/TestObjects/PersonTestModel.cs b/PTrampert.SimplePatch.Swashbuckle.Test/TestObjects/PersonTestModel.cs new file mode 100644 index 0000000..ca2d5b5 --- /dev/null +++ b/PTrampert.SimplePatch.Swashbuckle.Test/TestObjects/PersonTestModel.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace PTrampert.SimplePatch.Swashbuckle.Test.TestObjects; + +public record PersonTestModel +{ + [Required] + [StringLength(255, MinimumLength = 3)] + public required string Name { get; init; } + + public DateTime DateOfBirth { get; init; } + + [EmailAddress] + public string? Email { get; init; } + + [JsonPropertyName("nick_name")] + public string? NickName { get; init; } + + [JsonIgnore] + public string? Secret { get; init; } + + // Get-only, so the generated patch class cannot set it. The source model's schema still + // describes it, marked readOnly. + public string Display => Name; +} diff --git a/PTrampert.SimplePatch.Swashbuckle/PTrampert.SimplePatch.Swashbuckle.csproj b/PTrampert.SimplePatch.Swashbuckle/PTrampert.SimplePatch.Swashbuckle.csproj new file mode 100644 index 0000000..d0a96c4 --- /dev/null +++ b/PTrampert.SimplePatch.Swashbuckle/PTrampert.SimplePatch.Swashbuckle.csproj @@ -0,0 +1,32 @@ + + + + net8.0 + enable + enable + true + pjt15253 + Swashbuckle integration for PTrampert.SimplePatch. Documents routes taking an IPatchObject<T> with the patched model's schema instead of an empty object. + README.md + MIT + https://github.com/PaulTrampert/PTrampert.Optionals + https://github.com/PaulTrampert/PTrampert.Optionals/releases + https://github.com/PaulTrampert/PTrampert.Optionals.git + true + snupkg + + + + + + + + + + + + + + + + diff --git a/PTrampert.SimplePatch.Swashbuckle/PatchObjectSchemaFilter.cs b/PTrampert.SimplePatch.Swashbuckle/PatchObjectSchemaFilter.cs new file mode 100644 index 0000000..1991099 --- /dev/null +++ b/PTrampert.SimplePatch.Swashbuckle/PatchObjectSchemaFilter.cs @@ -0,0 +1,69 @@ +using System.Collections.Concurrent; +using Microsoft.OpenApi; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace PTrampert.SimplePatch.Swashbuckle; + +/// +/// Replaces the empty schema Swashbuckle generates for an request +/// body with the patched model's schema, with every property optional. +/// +/// +/// Register it with AddSimplePatchSchemas rather than +/// directly, so that the schema-id option is wired up too. +/// +/// The consumer's adjustments. +public class PatchObjectSchemaFilter(SimplePatchSchemaOptions options) : ISchemaFilter +{ + private static readonly ConcurrentDictionary> PatchablePropertyNamesBySourceType = new(); + + /// + public void Apply(IOpenApiSchema schema, SchemaFilterContext context) + { + // A filter can only mutate the schema in place — it cannot return a different one, and it + // cannot swap a $ref — so the source schema is copied across rather than substituted. + if (schema is not OpenApiSchema target) + { + return; + } + + // Only the interface is documented; the generated patch class never reaches the document. + if (!context.Type.IsInterface || !context.Type.TryGetPatchSourceType(out var sourceType)) + { + return; + } + + // Generates the source model's schema if the document does not have it yet, and registers + // it as a component, so a model used by both PUT and PATCH is described exactly once. + var generated = context.SchemaGenerator.GenerateSchema(sourceType, context.SchemaRepository); + if (Resolve(generated, context.SchemaRepository) is not { } source) + { + return; + } + + PatchSchemaTransform.Apply( + target, + source, + sourceType, + GetPatchablePropertyNames(sourceType, context), + options); + } + + private static HashSet GetPatchablePropertyNames(Type sourceType, SchemaFilterContext context) => + PatchablePropertyNamesBySourceType.GetOrAdd(sourceType, _ => + { + // Let Swashbuckle name the generated patch class's properties itself, so the names agree + // with whatever JsonSerializerOptions the application configured — Swashbuckle resolves + // those internally and exposes them to neither filters nor DI. A throwaway repository + // keeps the Optional component schemas this produces out of the real document. + var patchType = PatchClassBuilder.Instance.GetPatchClassFor(sourceType); + var throwaway = new SchemaRepository(context.DocumentName); + var patchSchema = context.SchemaGenerator.GenerateSchema(patchType, throwaway); + return Resolve(patchSchema, throwaway)?.Properties?.Keys.ToHashSet(StringComparer.Ordinal) ?? []; + }); + + private static IOpenApiSchema? Resolve(IOpenApiSchema schema, SchemaRepository repository) => + schema is OpenApiSchemaReference reference + ? repository.Schemas.GetValueOrDefault(reference.Reference.Id) + : schema; +} diff --git a/PTrampert.SimplePatch.Swashbuckle/SimplePatchSwaggerGenOptionsExtensions.cs b/PTrampert.SimplePatch.Swashbuckle/SimplePatchSwaggerGenOptionsExtensions.cs new file mode 100644 index 0000000..c5fe6d7 --- /dev/null +++ b/PTrampert.SimplePatch.Swashbuckle/SimplePatchSwaggerGenOptionsExtensions.cs @@ -0,0 +1,49 @@ +using PTrampert.SimplePatch; +using PTrampert.SimplePatch.Swashbuckle; +using Swashbuckle.AspNetCore.SwaggerGen; + +// Matches where Swashbuckle puts its own SwaggerGenOptions extensions, so this is in scope +// wherever AddSwaggerGen is. +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Adds SimplePatch's OpenAPI support to Swashbuckle. +/// +public static class SimplePatchSwaggerGenOptionsExtensions +{ + /// + /// Documents routes taking an request body with the patched + /// model's schema, with every property optional, instead of the empty object Swashbuckle + /// generates for the interface. + /// + /// + /// + /// builder.Services.AddSwaggerGen(options => options.AddSimplePatchSchemas()); + /// + /// + /// The Swashbuckle options to add the filter to. + /// Optional adjustments to the generated schema. + /// , for chaining. + public static SwaggerGenOptions AddSimplePatchSchemas( + this SwaggerGenOptions options, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(options); + + var schemaOptions = new SimplePatchSchemaOptions(); + configure?.Invoke(schemaOptions); + + options.SchemaFilter(schemaOptions); + + if (schemaOptions.SchemaId is { } schemaId) + { + var inner = options.SchemaGeneratorOptions.SchemaIdSelector; + options.SchemaGeneratorOptions.SchemaIdSelector = type => + type.IsInterface && type.TryGetPatchSourceType(out var sourceType) + ? schemaId(sourceType) ?? inner(type) + : inner(type); + } + + return options; + } +} diff --git a/PTrampert.SimplePatch.Test/PatchClassBuilderTest.cs b/PTrampert.SimplePatch.Test/PatchClassBuilderTest.cs index 9d824fa..e11523c 100644 --- a/PTrampert.SimplePatch.Test/PatchClassBuilderTest.cs +++ b/PTrampert.SimplePatch.Test/PatchClassBuilderTest.cs @@ -9,9 +9,7 @@ public class PatchClassBuilderTest [Test] public void GetPatchClassFor_CopiesThePropertiesAsOptionals() { - var builder = new PatchClassBuilder(); - - var optionalsType = builder.GetPatchClassFor(typeof(OptionalsBuilderTestObject)); + var optionalsType = PatchClassBuilder.Instance.GetPatchClassFor(typeof(OptionalsBuilderTestObject)); Assert.Multiple((Action)(() => { @@ -39,8 +37,7 @@ public void DynamicOptionalsClass_CanBeCreatedAndUsed() DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, }; options.Converters.Add(new OptionalJsonConverterFactory()); - var builder = new PatchClassBuilder(); - var optionalsType = builder.GetPatchClassFor(typeof(OptionalsBuilderTestObject)); + var optionalsType = PatchClassBuilder.Instance.GetPatchClassFor(typeof(OptionalsBuilderTestObject)); var instance = JsonSerializer.Deserialize(json, optionalsType, options) as IPatchObject; @@ -59,4 +56,33 @@ public void DynamicOptionalsClass_CanBeCreatedAndUsed() FakeStringProp = "FakeString:Fake Value" })); } + + [Test] + public void GetPatchClassFor_SharesGeneratedTypesAcrossBuilders() + { + // Deliberately the obsolete constructor: the point of this test is that separately + // constructed builders still share one cache, for as long as that constructor exists. +#pragma warning disable CS0618 + var first = new PatchClassBuilder().GetPatchClassFor(typeof(OptionalsBuilderTestObject)); + var second = new PatchClassBuilder().GetPatchClassFor(typeof(OptionalsBuilderTestObject)); +#pragma warning restore CS0618 + + Assert.Multiple((Action)(() => + { + Assert.That(second, Is.SameAs(first), + "Every builder should resolve a source type to one generated patch type, rather than each emitting its own dynamic assembly for it."); + Assert.That(PatchClassBuilder.Instance.GetPatchClassFor(typeof(OptionalsBuilderTestObject)), Is.SameAs(first)); + })); + } + + [Test] + public void GetPatchClassFor_SupportsSourceTypesInTheGlobalNamespace() + { + var globalNamespaceType = typeof(GlobalNamespaceTestObject); + Assert.That(globalNamespaceType.Namespace, Is.Null, "Guard: this test object must stay in the global namespace."); + + var patchType = PatchClassBuilder.Instance.GetPatchClassFor(globalNamespaceType); + + Assert.That(patchType.GetProperty(nameof(GlobalNamespaceTestObject.Name)), Is.Not.Null); + } } diff --git a/PTrampert.SimplePatch.Test/TestObjects/GlobalNamespaceTestObject.cs b/PTrampert.SimplePatch.Test/TestObjects/GlobalNamespaceTestObject.cs new file mode 100644 index 0000000..ea8aa2c --- /dev/null +++ b/PTrampert.SimplePatch.Test/TestObjects/GlobalNamespaceTestObject.cs @@ -0,0 +1,6 @@ +// Deliberately in the global namespace: patch class generation used to fail for source types +// without one, which minimal-API apps declaring models in Program.cs hit immediately. +public record GlobalNamespaceTestObject +{ + public string? Name { get; init; } +} diff --git a/PTrampert.SimplePatch.Test/TypeExtensionsTest.cs b/PTrampert.SimplePatch.Test/TypeExtensionsTest.cs new file mode 100644 index 0000000..19eeb87 --- /dev/null +++ b/PTrampert.SimplePatch.Test/TypeExtensionsTest.cs @@ -0,0 +1,44 @@ +using PTrampert.SimplePatch.Test.TestObjects; + +namespace PTrampert.SimplePatch.Test; + +public class TypeExtensionsTest +{ + [Test] + public void TryGetPatchSourceType_ResolvesTheOpenInterface() + { + var result = typeof(IPatchObject).TryGetPatchSourceType(out var sourceType); + + Assert.Multiple((Action)(() => + { + Assert.That(result, Is.True); + Assert.That(sourceType, Is.EqualTo(typeof(OptionalsBuilderTestObject))); + })); + } + + [Test] + public void TryGetPatchSourceType_ResolvesAGeneratedPatchClass() + { + var patchType = PatchClassBuilder.Instance.GetPatchClassFor(typeof(OptionalsBuilderTestObject)); + + var result = patchType.TryGetPatchSourceType(out var sourceType); + + Assert.Multiple((Action)(() => + { + Assert.That(result, Is.True); + Assert.That(sourceType, Is.EqualTo(typeof(OptionalsBuilderTestObject))); + })); + } + + [Test] + public void TryGetPatchSourceType_IsFalseForUnrelatedTypes() + { + var result = typeof(OptionalsBuilderTestObject).TryGetPatchSourceType(out var sourceType); + + Assert.Multiple((Action)(() => + { + Assert.That(result, Is.False); + Assert.That(sourceType, Is.Null); + })); + } +} diff --git a/PTrampert.SimplePatch.sln b/PTrampert.SimplePatch.sln index 4ddf6f2..04a7853 100644 --- a/PTrampert.SimplePatch.sln +++ b/PTrampert.SimplePatch.sln @@ -6,23 +6,124 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PTrampert.SimplePatch.Test" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PTrampert.SimplePatch.Sample", "PTrampert.SimplePatch.Sample\PTrampert.SimplePatch.Sample.csproj", "{2B3CA20D-DC34-41F6-A8A6-E9C5B965BE53}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PTrampert.SimplePatch.Swashbuckle", "PTrampert.SimplePatch.Swashbuckle\PTrampert.SimplePatch.Swashbuckle.csproj", "{60AE2761-9E2C-49CC-B60F-CE80A93EC167}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PTrampert.SimplePatch.OpenApi", "PTrampert.SimplePatch.OpenApi\PTrampert.SimplePatch.OpenApi.csproj", "{BA9BE017-83FB-4BDD-B1D1-30BC009B0B18}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PTrampert.SimplePatch.Swashbuckle.Test", "PTrampert.SimplePatch.Swashbuckle.Test\PTrampert.SimplePatch.Swashbuckle.Test.csproj", "{66023665-4977-47C4-A1AC-A6F788B85163}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PTrampert.SimplePatch.OpenApi.Test", "PTrampert.SimplePatch.OpenApi.Test\PTrampert.SimplePatch.OpenApi.Test.csproj", "{D7B9496F-7292-428C-B145-D1EC21271125}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PTrampert.SimplePatch.Schema", "PTrampert.SimplePatch.Schema\PTrampert.SimplePatch.Schema.csproj", "{B887D919-9F17-40EA-B6AC-16A4AE157C6E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {2B37F686-9994-468D-9390-2A9862F98383}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2B37F686-9994-468D-9390-2A9862F98383}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2B37F686-9994-468D-9390-2A9862F98383}.Debug|x64.ActiveCfg = Debug|Any CPU + {2B37F686-9994-468D-9390-2A9862F98383}.Debug|x64.Build.0 = Debug|Any CPU + {2B37F686-9994-468D-9390-2A9862F98383}.Debug|x86.ActiveCfg = Debug|Any CPU + {2B37F686-9994-468D-9390-2A9862F98383}.Debug|x86.Build.0 = Debug|Any CPU {2B37F686-9994-468D-9390-2A9862F98383}.Release|Any CPU.ActiveCfg = Release|Any CPU {2B37F686-9994-468D-9390-2A9862F98383}.Release|Any CPU.Build.0 = Release|Any CPU + {2B37F686-9994-468D-9390-2A9862F98383}.Release|x64.ActiveCfg = Release|Any CPU + {2B37F686-9994-468D-9390-2A9862F98383}.Release|x64.Build.0 = Release|Any CPU + {2B37F686-9994-468D-9390-2A9862F98383}.Release|x86.ActiveCfg = Release|Any CPU + {2B37F686-9994-468D-9390-2A9862F98383}.Release|x86.Build.0 = Release|Any CPU {8DDE21D6-55C2-47A8-BA63-468AC5A43B6A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {8DDE21D6-55C2-47A8-BA63-468AC5A43B6A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8DDE21D6-55C2-47A8-BA63-468AC5A43B6A}.Debug|x64.ActiveCfg = Debug|Any CPU + {8DDE21D6-55C2-47A8-BA63-468AC5A43B6A}.Debug|x64.Build.0 = Debug|Any CPU + {8DDE21D6-55C2-47A8-BA63-468AC5A43B6A}.Debug|x86.ActiveCfg = Debug|Any CPU + {8DDE21D6-55C2-47A8-BA63-468AC5A43B6A}.Debug|x86.Build.0 = Debug|Any CPU {8DDE21D6-55C2-47A8-BA63-468AC5A43B6A}.Release|Any CPU.ActiveCfg = Release|Any CPU {8DDE21D6-55C2-47A8-BA63-468AC5A43B6A}.Release|Any CPU.Build.0 = Release|Any CPU + {8DDE21D6-55C2-47A8-BA63-468AC5A43B6A}.Release|x64.ActiveCfg = Release|Any CPU + {8DDE21D6-55C2-47A8-BA63-468AC5A43B6A}.Release|x64.Build.0 = Release|Any CPU + {8DDE21D6-55C2-47A8-BA63-468AC5A43B6A}.Release|x86.ActiveCfg = Release|Any CPU + {8DDE21D6-55C2-47A8-BA63-468AC5A43B6A}.Release|x86.Build.0 = Release|Any CPU {2B3CA20D-DC34-41F6-A8A6-E9C5B965BE53}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2B3CA20D-DC34-41F6-A8A6-E9C5B965BE53}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2B3CA20D-DC34-41F6-A8A6-E9C5B965BE53}.Debug|x64.ActiveCfg = Debug|Any CPU + {2B3CA20D-DC34-41F6-A8A6-E9C5B965BE53}.Debug|x64.Build.0 = Debug|Any CPU + {2B3CA20D-DC34-41F6-A8A6-E9C5B965BE53}.Debug|x86.ActiveCfg = Debug|Any CPU + {2B3CA20D-DC34-41F6-A8A6-E9C5B965BE53}.Debug|x86.Build.0 = Debug|Any CPU {2B3CA20D-DC34-41F6-A8A6-E9C5B965BE53}.Release|Any CPU.ActiveCfg = Release|Any CPU {2B3CA20D-DC34-41F6-A8A6-E9C5B965BE53}.Release|Any CPU.Build.0 = Release|Any CPU + {2B3CA20D-DC34-41F6-A8A6-E9C5B965BE53}.Release|x64.ActiveCfg = Release|Any CPU + {2B3CA20D-DC34-41F6-A8A6-E9C5B965BE53}.Release|x64.Build.0 = Release|Any CPU + {2B3CA20D-DC34-41F6-A8A6-E9C5B965BE53}.Release|x86.ActiveCfg = Release|Any CPU + {2B3CA20D-DC34-41F6-A8A6-E9C5B965BE53}.Release|x86.Build.0 = Release|Any CPU + {60AE2761-9E2C-49CC-B60F-CE80A93EC167}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {60AE2761-9E2C-49CC-B60F-CE80A93EC167}.Debug|Any CPU.Build.0 = Debug|Any CPU + {60AE2761-9E2C-49CC-B60F-CE80A93EC167}.Debug|x64.ActiveCfg = Debug|Any CPU + {60AE2761-9E2C-49CC-B60F-CE80A93EC167}.Debug|x64.Build.0 = Debug|Any CPU + {60AE2761-9E2C-49CC-B60F-CE80A93EC167}.Debug|x86.ActiveCfg = Debug|Any CPU + {60AE2761-9E2C-49CC-B60F-CE80A93EC167}.Debug|x86.Build.0 = Debug|Any CPU + {60AE2761-9E2C-49CC-B60F-CE80A93EC167}.Release|Any CPU.ActiveCfg = Release|Any CPU + {60AE2761-9E2C-49CC-B60F-CE80A93EC167}.Release|Any CPU.Build.0 = Release|Any CPU + {60AE2761-9E2C-49CC-B60F-CE80A93EC167}.Release|x64.ActiveCfg = Release|Any CPU + {60AE2761-9E2C-49CC-B60F-CE80A93EC167}.Release|x64.Build.0 = Release|Any CPU + {60AE2761-9E2C-49CC-B60F-CE80A93EC167}.Release|x86.ActiveCfg = Release|Any CPU + {60AE2761-9E2C-49CC-B60F-CE80A93EC167}.Release|x86.Build.0 = Release|Any CPU + {BA9BE017-83FB-4BDD-B1D1-30BC009B0B18}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BA9BE017-83FB-4BDD-B1D1-30BC009B0B18}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BA9BE017-83FB-4BDD-B1D1-30BC009B0B18}.Debug|x64.ActiveCfg = Debug|Any CPU + {BA9BE017-83FB-4BDD-B1D1-30BC009B0B18}.Debug|x64.Build.0 = Debug|Any CPU + {BA9BE017-83FB-4BDD-B1D1-30BC009B0B18}.Debug|x86.ActiveCfg = Debug|Any CPU + {BA9BE017-83FB-4BDD-B1D1-30BC009B0B18}.Debug|x86.Build.0 = Debug|Any CPU + {BA9BE017-83FB-4BDD-B1D1-30BC009B0B18}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BA9BE017-83FB-4BDD-B1D1-30BC009B0B18}.Release|Any CPU.Build.0 = Release|Any CPU + {BA9BE017-83FB-4BDD-B1D1-30BC009B0B18}.Release|x64.ActiveCfg = Release|Any CPU + {BA9BE017-83FB-4BDD-B1D1-30BC009B0B18}.Release|x64.Build.0 = Release|Any CPU + {BA9BE017-83FB-4BDD-B1D1-30BC009B0B18}.Release|x86.ActiveCfg = Release|Any CPU + {BA9BE017-83FB-4BDD-B1D1-30BC009B0B18}.Release|x86.Build.0 = Release|Any CPU + {66023665-4977-47C4-A1AC-A6F788B85163}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {66023665-4977-47C4-A1AC-A6F788B85163}.Debug|Any CPU.Build.0 = Debug|Any CPU + {66023665-4977-47C4-A1AC-A6F788B85163}.Debug|x64.ActiveCfg = Debug|Any CPU + {66023665-4977-47C4-A1AC-A6F788B85163}.Debug|x64.Build.0 = Debug|Any CPU + {66023665-4977-47C4-A1AC-A6F788B85163}.Debug|x86.ActiveCfg = Debug|Any CPU + {66023665-4977-47C4-A1AC-A6F788B85163}.Debug|x86.Build.0 = Debug|Any CPU + {66023665-4977-47C4-A1AC-A6F788B85163}.Release|Any CPU.ActiveCfg = Release|Any CPU + {66023665-4977-47C4-A1AC-A6F788B85163}.Release|Any CPU.Build.0 = Release|Any CPU + {66023665-4977-47C4-A1AC-A6F788B85163}.Release|x64.ActiveCfg = Release|Any CPU + {66023665-4977-47C4-A1AC-A6F788B85163}.Release|x64.Build.0 = Release|Any CPU + {66023665-4977-47C4-A1AC-A6F788B85163}.Release|x86.ActiveCfg = Release|Any CPU + {66023665-4977-47C4-A1AC-A6F788B85163}.Release|x86.Build.0 = Release|Any CPU + {D7B9496F-7292-428C-B145-D1EC21271125}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D7B9496F-7292-428C-B145-D1EC21271125}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D7B9496F-7292-428C-B145-D1EC21271125}.Debug|x64.ActiveCfg = Debug|Any CPU + {D7B9496F-7292-428C-B145-D1EC21271125}.Debug|x64.Build.0 = Debug|Any CPU + {D7B9496F-7292-428C-B145-D1EC21271125}.Debug|x86.ActiveCfg = Debug|Any CPU + {D7B9496F-7292-428C-B145-D1EC21271125}.Debug|x86.Build.0 = Debug|Any CPU + {D7B9496F-7292-428C-B145-D1EC21271125}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D7B9496F-7292-428C-B145-D1EC21271125}.Release|Any CPU.Build.0 = Release|Any CPU + {D7B9496F-7292-428C-B145-D1EC21271125}.Release|x64.ActiveCfg = Release|Any CPU + {D7B9496F-7292-428C-B145-D1EC21271125}.Release|x64.Build.0 = Release|Any CPU + {D7B9496F-7292-428C-B145-D1EC21271125}.Release|x86.ActiveCfg = Release|Any CPU + {D7B9496F-7292-428C-B145-D1EC21271125}.Release|x86.Build.0 = Release|Any CPU + {B887D919-9F17-40EA-B6AC-16A4AE157C6E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B887D919-9F17-40EA-B6AC-16A4AE157C6E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B887D919-9F17-40EA-B6AC-16A4AE157C6E}.Debug|x64.ActiveCfg = Debug|Any CPU + {B887D919-9F17-40EA-B6AC-16A4AE157C6E}.Debug|x64.Build.0 = Debug|Any CPU + {B887D919-9F17-40EA-B6AC-16A4AE157C6E}.Debug|x86.ActiveCfg = Debug|Any CPU + {B887D919-9F17-40EA-B6AC-16A4AE157C6E}.Debug|x86.Build.0 = Debug|Any CPU + {B887D919-9F17-40EA-B6AC-16A4AE157C6E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B887D919-9F17-40EA-B6AC-16A4AE157C6E}.Release|Any CPU.Build.0 = Release|Any CPU + {B887D919-9F17-40EA-B6AC-16A4AE157C6E}.Release|x64.ActiveCfg = Release|Any CPU + {B887D919-9F17-40EA-B6AC-16A4AE157C6E}.Release|x64.Build.0 = Release|Any CPU + {B887D919-9F17-40EA-B6AC-16A4AE157C6E}.Release|x86.ActiveCfg = Release|Any CPU + {B887D919-9F17-40EA-B6AC-16A4AE157C6E}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE EndGlobalSection EndGlobal diff --git a/PTrampert.SimplePatch/PatchClassBuilder.cs b/PTrampert.SimplePatch/PatchClassBuilder.cs index ce73f0d..363100f 100644 --- a/PTrampert.SimplePatch/PatchClassBuilder.cs +++ b/PTrampert.SimplePatch/PatchClassBuilder.cs @@ -17,7 +17,36 @@ namespace PTrampert.SimplePatch; public class PatchClassBuilder { private const string ApplyTargetParamName = "target"; - private readonly ConcurrentDictionary _optionalsClasses = new(); + + /// + /// Namespace used for patch classes generated from source types that are not themselves + /// in a namespace. + /// + private const string GlobalNamespaceFallback = "PTrampert.SimplePatch.Generated"; + + // Static so that every builder — the one used by PatchJsonConverterFactory, and any the + // OpenAPI integrations or user code create — resolves a given source type to the same + // generated patch type, instead of each emitting its own dynamic assembly for it. + private static readonly ConcurrentDictionary OptionalsClasses = new(); + + /// + /// The builder. Use this rather than constructing your own: all instances share one cache, so + /// a new instance buys nothing but an allocation. + /// +#pragma warning disable CS0618 // The obsolete constructor is how the singleton itself is built. + public static PatchClassBuilder Instance { get; } = new(); +#pragma warning restore CS0618 + + /// + /// Creates a builder. + /// + [Obsolete("Use PatchClassBuilder.Instance instead. Every builder shares one cache, so a new " + + "instance buys nothing but an allocation. This constructor will be made internal " + + "in the next major version: " + + "https://github.com/PaulTrampert/PTrampert.SimplePatch/issues/75")] + public PatchClassBuilder() + { + } /// /// Gets or creates a class that implements for the specified type. @@ -33,13 +62,14 @@ public class PatchClassBuilder /// The generated patch type. public Type GetPatchClassFor(Type type) { - return _optionalsClasses.GetOrAdd(type, CreatePatchClass); + return OptionalsClasses.GetOrAdd(type, CreatePatchClass); } private static Type CreatePatchClass(Type type) { var unit = new CodeCompileUnit(); - var ns = new CodeNamespace($"{type.Namespace}.Optionals"); + var namespaceRoot = string.IsNullOrEmpty(type.Namespace) ? GlobalNamespaceFallback : type.Namespace; + var ns = new CodeNamespace($"{namespaceRoot}.Optionals"); unit.Namespaces.Add(ns); var className = $"{type.Name}_Optionals_{Path.GetRandomFileName().Replace('.', '_')}"; var classType = new CodeTypeDeclaration(className) diff --git a/PTrampert.SimplePatch/PatchJsonConverterFactory.cs b/PTrampert.SimplePatch/PatchJsonConverterFactory.cs index 2dc31bf..92863fa 100644 --- a/PTrampert.SimplePatch/PatchJsonConverterFactory.cs +++ b/PTrampert.SimplePatch/PatchJsonConverterFactory.cs @@ -13,8 +13,6 @@ namespace PTrampert.SimplePatch; /// public class PatchJsonConverterFactory : JsonConverterFactory { - private readonly PatchClassBuilder _patchClassBuilder = new(); - /// /// Returns true if the specified type is a generic type of . /// @@ -30,7 +28,7 @@ public override bool CanConvert(Type typeToConvert) public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) { var baseObjectType = typeToConvert.GetGenericArguments()[0]; - var concreteTypeToConvert = _patchClassBuilder.GetPatchClassFor(baseObjectType); + var concreteTypeToConvert = PatchClassBuilder.Instance.GetPatchClassFor(baseObjectType); return (JsonConverter?)Activator.CreateInstance( typeof(PatchObjectJsonConverter<,>).MakeGenericType(concreteTypeToConvert, baseObjectType), BindingFlags.Public | BindingFlags.Instance, diff --git a/PTrampert.SimplePatch/TypeExtensions.cs b/PTrampert.SimplePatch/TypeExtensions.cs index 9db301a..92c0599 100644 --- a/PTrampert.SimplePatch/TypeExtensions.cs +++ b/PTrampert.SimplePatch/TypeExtensions.cs @@ -1,14 +1,45 @@ +using System.Diagnostics.CodeAnalysis; + namespace PTrampert.SimplePatch; -internal static class TypeExtensions +/// +/// Reflection helpers for working with types. +/// +public static class TypeExtensions { - public static bool IsPatchObjectType(this Type type) + /// + /// Gets the type a patch object patches. + /// Accepts both the open interface (IPatchObject<Person>) and any concrete + /// implementation of it, such as the classes produced by . + /// + /// The type to inspect. + /// The patched type, or null if is not a patch object. + /// True if is, or implements, . + public static bool TryGetPatchSourceType(this Type type, [NotNullWhen(true)] out Type? sourceType) + { + ArgumentNullException.ThrowIfNull(type); + + if (IsPatchObjectInterface(type)) + { + sourceType = type.GetGenericArguments()[0]; + return true; + } + + sourceType = type.GetInterfaces() + .FirstOrDefault(IsPatchObjectInterface) + ?.GetGenericArguments()[0]; + return sourceType != null; + } + + private static bool IsPatchObjectInterface(Type type) => + type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IPatchObject<>); + + internal static bool IsPatchObjectType(this Type type) { - var interfaces = type.GetInterfaces(); - return interfaces.Any(iface => iface.IsGenericType && iface.GetGenericTypeDefinition() == typeof(IPatchObject<>)); + return type.GetInterfaces().Any(IsPatchObjectInterface); } - - public static Type GetPatchObjectType(this Type type) + + internal static Type GetPatchObjectType(this Type type) { if (!type.IsPatchObjectType()) { @@ -16,7 +47,7 @@ public static Type GetPatchObjectType(this Type type) } return type.GetInterfaces() - .First(iface => iface.IsGenericType && iface.GetGenericTypeDefinition() == typeof(IPatchObject<>)) + .First(IsPatchObjectInterface) .GetGenericArguments()[0]; } -} \ No newline at end of file +} diff --git a/README.md b/README.md index a8885fb..44a00d6 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ and your object contains additional properties besides `name`, PTrampert.SimpleP - Dynamically generates implementations of `IPatchObject` for your write models. - Preserves validation attributes on properties, allowing for validation of PATCH requests. - Handles complex types with custom JSON converters. +- Documents PATCH routes properly in OpenAPI, via optional Swashbuckle and + `Microsoft.AspNetCore.OpenApi` integration packages. ## Getting Started A full sample project is available in the [PTrampert.SimplePatch.Samples](./PTrampert.SimplePatch.Sample) @@ -83,3 +85,81 @@ Content-Length: 24 "name": "New Name" } ``` + +## OpenAPI + +Out of the box, an OpenAPI generator describes a `[FromBody] IPatchObject` parameter from the +interface, which has no properties. The request body ends up documented as an empty object — +useless to a reader, and strict enough to reject every legal PATCH: + +```json +"PersonWriteModelIPatchObject": { "type": "object", "additionalProperties": false } +``` + +Install the integration package for your generator to document the patched model's schema instead, +with every property optional. + +### Swashbuckle + +```bash +dotnet add package PTrampert.SimplePatch.Swashbuckle +``` + +```csharp +builder.Services.AddSwaggerGen(options => options.AddSimplePatchSchemas()); +``` + +### Microsoft.AspNetCore.OpenApi (.NET 10+) + +```bash +dotnet add package PTrampert.SimplePatch.OpenApi +``` + +```csharp +builder.Services.AddOpenApi(options => options.AddSimplePatchSchemas()); +``` + +Either way the PATCH body is now described with the model's own properties, validation constraints +and converters, and nothing marked required: + +```json +"PersonWriteModelIPatchObject": { + "type": "object", + "properties": { + "name": { "maxLength": 255, "minLength": 3, "type": "string" }, + "dateOfBirth": { "type": "string", "format": "date-time" }, + "email": { "type": "string", "format": "email", "nullable": true }, + "phoneNumber": { "$ref": "#/components/schemas/PhoneNumber" } + }, + "additionalProperties": false, + "description": "Partial update of PersonWriteModel. Omitted properties are left unchanged." +} +``` + +Because the patch schema is derived from the model's own schema, anything you already do to tune +that model — a `MapType`, a schema filter, XML comments — applies to the PATCH body too, and the +two can never describe different shapes. + +### Setting an example + +Swagger UI builds its example from the schema's properties and ignores `required`, so by default +the PATCH example lists *every* property — which reads as "send all of these", and is what +**Try it out** will submit. Supply an example to show the partial-update semantics instead: + +```csharp +builder.Services.AddSwaggerGen(options => options.AddSimplePatchSchemas(patch => +{ + patch.Example = _ => new JsonObject { ["name"] = "New Name" }; +})); +``` + +### Other options + +`AddSimplePatchSchemas` takes a `SimplePatchSchemaOptions`: + +| Option | Default | Effect | +| --- | --- | --- | +| `Example` | null | Example body for the patch schema. See above. | +| `SchemaId` | null | Names the patch schema component. `t => t.Name + "Patch"` gives `PersonWriteModelPatch` instead of `PersonWriteModelIPatchObject`. | +| `DescriptionFormat` | `"Partial update of {0}. Omitted properties are left unchanged."` | The patch schema's description. Null leaves it alone. | +| `ClearRequired` | true | Drops `required`, which is what makes the body a partial update. | diff --git a/docfx.json b/docfx.json index 8037762..d8777b2 100644 --- a/docfx.json +++ b/docfx.json @@ -8,6 +8,24 @@ "files": [ "**/*.csproj" ] + }, + { + "src": "PTrampert.SimplePatch.Schema", + "files": [ + "**/*.csproj" + ] + }, + { + "src": "PTrampert.SimplePatch.Swashbuckle", + "files": [ + "**/*.csproj" + ] + }, + { + "src": "PTrampert.SimplePatch.OpenApi", + "files": [ + "**/*.csproj" + ] } ], "dest": "api" diff --git a/docs/getting-started.md b/docs/getting-started.md index 3dba822..3ac0ec6 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -57,3 +57,31 @@ dotnet add package PTrampert.SimplePatch return Ok(updatedPerson); } ``` + +4. **Document the Endpoint in OpenAPI** + + An OpenAPI generator describes the `IPatchObject` parameter from the interface, which has no + properties, so the request body is documented as an empty object. Add the integration package + for your generator to document the patched model's schema instead, with every property + optional: + + ```sh + dotnet add package PTrampert.SimplePatch.Swashbuckle + ``` + + ```csharp + builder.Services.AddSwaggerGen(options => options.AddSimplePatchSchemas(patch => + { + // Swagger UI's generated example lists every property, which reads as "send all of these". + // An explicit example shows the partial-update semantics instead. + patch.Example = _ => new JsonObject { ["firstName"] = "New Name" }; + })); + ``` + + On .NET 10 and later, `PTrampert.SimplePatch.OpenApi` does the same for the built-in generator: + + ```csharp + builder.Services.AddOpenApi(options => options.AddSimplePatchSchemas()); + ``` + + See the [README](../README.md#openapi) for the full set of options. diff --git a/docs/proposals/openapi-contract-generation.md b/docs/proposals/openapi-contract-generation.md new file mode 100644 index 0000000..4fc9f21 --- /dev/null +++ b/docs/proposals/openapi-contract-generation.md @@ -0,0 +1,443 @@ +# Proposal: OpenAPI contract generation for `IPatchObject` + +Status: implemented — see `PTrampert.SimplePatch.Swashbuckle` and `PTrampert.SimplePatch.OpenApi`. +This document is kept as the record of the design decision and the alternatives weighed. +Applies to: `PTrampert.SimplePatch` 1.x + +One thing changed during implementation: the built-in-OpenAPI package targets **net10.0 only**, not +net9.0 as proposed in §3. Obtaining the source model's schema from a transformer needs +`OpenApiSchemaTransformerContext.GetOrCreateSchemaAsync`, which .NET 10 added and .NET 9 does not +have. Supporting .NET 9 would need a different mechanism, and .NET 9's OpenAPI.NET 1.6 object model +would also fork the shared transform. Swashbuckle covers .NET 8 and 9 in the meantime. + +## 1. The problem + +A route that takes `[FromBody] IPatchObject` is documented from the +*interface*. Neither Swashbuckle nor the built-in `Microsoft.AspNetCore.OpenApi` +generator knows about the class `PatchClassBuilder` emits at runtime, and the interface +has no properties — only a `Patch` method. The sample app today emits: + +```json +"PersonWriteModelIPatchObject": { + "type": "object", + "additionalProperties": false +} +``` + +This is worse than merely uninformative. `additionalProperties: false` with no +`properties` describes a body that permits **no** members at all, so a strict validator, +a contract test, or a generated client will reject every legal PATCH request. Generated +clients get a marker class with no members; consumers have to read the source to learn +what they may send. + +The desired document for that same route is the write model's schema with PATCH +semantics applied: + +```json +"PersonWriteModelIPatchObject": { + "type": "object", + "properties": { + "name": { "maxLength": 255, "minLength": 3, "type": "string" }, + "dateOfBirth": { "type": "string", "format": "date-time" }, + "email": { "type": "string", "format": "email", "nullable": true }, + "phoneNumber": { "$ref": "#/components/schemas/PhoneNumber" } + }, + "additionalProperties": false, + "description": "Partial update of PersonWriteModel. Omitted properties are left unchanged." +} +``` + +## 2. The central design decision + +There are two ways to produce that schema, and the choice drives everything else. + +**Option A — describe the generated patch class.** Hand `PatchClassBuilder`'s runtime +type to the schema generator and teach it to unwrap `Optional`. + +**Option B — derive the schema from the source model `T`.** Ask the generator for the +schema it already produces for `PersonWriteModel`, then apply the patch transform: +drop `required`, drop properties the patch class cannot set, add a description. + +**This proposal recommends Option B.** Option A has to re-solve four problems that the +generator has already solved correctly for the source model: + +| Concern | Option A | Option B | +| --- | --- | --- | +| `Optional` properties | documents as `{ hasValue, value }` unless separately unwrapped, in *both* generators | never seen | +| Schema id | `PersonWriteModel_Optionals_xj1k2h3g` — the random name from `Path.GetRandomFileName()` leaks into the public contract, and changes every run | derived from `T` | +| Validation constraints | `PatchClassBuilder` rewrites `[StringLength]` into `[OptionalValidation(typeof(StringLengthAttribute))]`, which no schema generator recognizes, so `minLength`/`maxLength`/`format` are all lost | inherited from `T`'s schema | +| `[JsonConverter]` properties | the generated property carries `[OptionalConverter]`, not the original converter | inherited from `T`'s schema | + +Option B also composes. If a consumer already tunes their write model's schema — a +`MapType`, a schema filter, an XML comment — the patch schema picks the +tuning up for free, because it is built from that same schema. That coupling is +semantically right: *the patch contract for a model is the model's contract with +optional members*, so the two should never be able to drift. + +The one thing Option B must still consult the generated class for is **which properties +are patchable**. `PatchClassBuilder` skips properties where `CanWrite` is false, but +the source model's schema includes them (marked `readOnly`). So the transform +intersects the source schema's properties with the patch class's JSON property names, +resolved through the application's own `JsonSerializerOptions` so that any naming policy +is respected. + +## 3. Packaging + +Keep the core package free of any OpenAPI dependency and ship two integration packages +plus the schema package they share: + +| Package | Target | Depends on | Hook | +| --- | --- | --- | --- | +| `PTrampert.SimplePatch.Schema` | net8.0 | `Microsoft.OpenApi`, core | the shared transform | +| `PTrampert.SimplePatch.Swashbuckle` | net8.0 | `Swashbuckle.AspNetCore.SwaggerGen`, schema | `ISchemaFilter` | +| `PTrampert.SimplePatch.OpenApi` | net9.0, net10.0 | `Microsoft.AspNetCore.OpenApi`, schema | `IOpenApiSchemaTransformer` | + +Two packages rather than one multi-targeted package, because Swashbuckle 10 moved from +Microsoft.OpenApi 1.6.x to 2.x, where `OpenApiSchema` became `IOpenApiSchema`, +`ISchemaFilter.Apply` takes `IOpenApiSchema`, and a `$ref` is an `OpenApiSchemaReference` +rather than a schema with a `Reference` property. Supporting Swashbuckle 6–9 as well +means `#if`-ing across that split; a separate package can simply declare the range it +supports — recommendation is to target Swashbuckle 10 only, matching the sample. +Both integration packages are small (roughly 50 lines each), so duplicating the +transform logic between them costs less than straddling the API break. + +The transform and `SimplePatchSchemaOptions` live in `PTrampert.SimplePatch.Schema`, which +both integration packages reference. They cannot live in the core package: the transform +takes `Microsoft.OpenApi` types, so hosting it there would hand a `Microsoft.OpenApi` +dependency to every consumer of `PTrampert.SimplePatch`, OpenAPI user or not. A separate +package keeps one copy of the transform — so the two integrations cannot drift — while the +core package's dependency list stays as it was. It targets net8.0, which both the net8.0 +Swashbuckle package and the net10.0 built-in package can consume. + +Consumers opt in with a single call: + +```csharp +// Swashbuckle +builder.Services.AddSwaggerGen(o => o.AddSimplePatchSchemas()); + +// .NET 9+ built-in +builder.Services.AddOpenApi(o => o.AddSimplePatchSchemas()); +``` + +with an options overload for the tuneable parts: + +```csharp +public sealed class SimplePatchSchemaOptions +{ + /// Names the patch schema. Default: leave the generator's id alone. + public Func? SchemaId { get; set; } + + /// Format string for the schema description; {0} is the source type name. + /// Set to null to leave the description alone. + public string? DescriptionFormat { get; set; } + = "Partial update of {0}. Omitted properties are left unchanged."; + + /// Clear required on the patch schema. Default true. + public bool ClearRequired { get; set; } = true; + + /// Supplies the example body for a patch schema. Default null, which lets + /// the UI synthesize one from the properties — see §7. + public Func? Example { get; set; } +} +``` + +## 4. Changes required in the core package + +These are prerequisites, not nice-to-haves. + +1. **Share the patch class cache.** `PatchJsonConverterFactory` news up its own private + `PatchClassBuilder`. An OpenAPI integration that news up a second one compiles a + *second* dynamic assembly for the same source type — wasted Roslyn work at startup, + and two types that are structurally identical but not reference-equal. Make the + `_optionalsClasses` cache `static`, or expose a `PatchClassBuilder.Instance` singleton + and have both the converter factory and the integrations use it. + +2. **Make the patch-type reflection helpers public.** `TypeExtensions.IsPatchObjectType` + and `GetPatchObjectType` are `internal`, and they answer a slightly different + question than the integrations ask — they test whether a *concrete* type implements + `IPatchObject<>`, while a schema filter is handed the open interface + `IPatchObject` itself. Add and make public: + + ```csharp + public static bool TryGetPatchSourceType(this Type type, [NotNullWhen(true)] out Type? sourceType); + ``` + + returning true for both `IPatchObject` and any concrete implementation of it. + +3. **Fix `PatchClassBuilder` for types in the global namespace.** Found while + prototyping: when `type.Namespace` is null the generated namespace is `".Optionals"`, + and compilation fails with `error CS1001: Identifier expected`. Minimal-API apps + routinely declare models in `Program.cs`, so this is hit immediately by the audience + most likely to adopt the built-in OpenAPI integration. Fall back to a fixed + namespace such as `SimplePatch.Optionals` when `Namespace` is null. This is an + independent bug and could be fixed ahead of this work. + +## 5. Implementation + +Both implementations below were prototyped against this repository's sample app and +produce the verified output in §6. + +### 5.1 Swashbuckle (`ISchemaFilter`) + +```csharp +public sealed class PatchObjectSchemaFilter( + IOptions jsonOptions, + SimplePatchSchemaOptions options) : ISchemaFilter +{ + public void Apply(IOpenApiSchema schema, SchemaFilterContext context) + { + if (schema is not OpenApiSchema target) return; + if (!context.Type.TryGetPatchSourceType(out var sourceType)) return; + + // Generate (and register) the source model's schema, then resolve the $ref. + var generated = context.SchemaGenerator.GenerateSchema(sourceType, context.SchemaRepository); + if (Resolve(generated, context.SchemaRepository) is not { } source) return; + + var patchType = PatchClassBuilder.Instance.GetPatchClassFor(sourceType); + var patchable = jsonOptions.Value.JsonSerializerOptions + .GetTypeInfo(patchType).Properties + .Select(p => p.Name) + .ToHashSet(StringComparer.Ordinal); + + target.Type = source.Type; + target.AdditionalPropertiesAllowed = source.AdditionalPropertiesAllowed; + target.Properties = source.Properties? + .Where(kvp => patchable.Contains(kvp.Key)) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + + if (options.ClearRequired) target.Required = null; + if (options.DescriptionFormat is { } format) + target.Description = string.Format(format, sourceType.Name); + } + + private static IOpenApiSchema? Resolve(IOpenApiSchema schema, SchemaRepository repository) => + schema is OpenApiSchemaReference reference + ? repository.Schemas.GetValueOrDefault(reference.Reference.Id) + : schema; +} +``` + +A schema filter mutates the schema in place and cannot swap a `$ref` for another, which +is exactly why the transform copies members across rather than returning the source +schema. Property values are copied by reference, so `phoneNumber` keeps pointing at the +shared `#/components/schemas/PhoneNumber` component rather than being duplicated. + +### 5.2 Built-in OpenAPI, .NET 9+ (`IOpenApiSchemaTransformer`) + +```csharp +public sealed class PatchObjectSchemaTransformer(SimplePatchSchemaOptions options) + : IOpenApiSchemaTransformer +{ + public async Task TransformAsync( + OpenApiSchema schema, + OpenApiSchemaTransformerContext context, + CancellationToken cancellationToken) + { + if (!context.JsonTypeInfo.Type.TryGetPatchSourceType(out var sourceType)) return; + + var source = await context.GetOrCreateSchemaAsync(sourceType, cancellationToken: cancellationToken); + var patchType = PatchClassBuilder.Instance.GetPatchClassFor(sourceType); + var patchable = /* same JsonTypeInfo lookup as above */; + + schema.Type = source.Type; + schema.Properties = source.Properties? + .Where(kvp => patchable.Contains(kvp.Key)) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + + if (options.ClearRequired) schema.Required = null; + if (options.DescriptionFormat is { } format) + schema.Description = string.Format(format, sourceType.Name); + } +} +``` + +The two differ only in how the source schema is obtained and in the OpenAPI object +model's mutability rules; the transform itself is identical and lives once, in +`PTrampert.SimplePatch.Schema`, which both packages reference — see §3. + +## 6. Verified output + +Prototyped against `PTrampert.SimplePatch.Sample` (Swashbuckle 10.2.3, Microsoft.OpenApi +2.7.5) and against a minimal-API app on .NET 10 using `AddOpenApi`. + +Before — Swashbuckle: + +```json +"PersonWriteModelIPatchObject": { "type": "object", "additionalProperties": false } +``` + +After — Swashbuckle, with a get-only `Display` property added to `PersonWriteModel` to +exercise the patchable-property filter: + +```json +"PersonWriteModel": { + "required": ["name"], + "type": "object", + "properties": { + "name": { "maxLength": 255, "minLength": 3, "type": "string" }, + "dateOfBirth": { "type": "string", "format": "date-time" }, + "display": { "type": "string", "nullable": true, "readOnly": true }, + "email": { "type": "string", "format": "email", "nullable": true }, + "phoneNumber": { "$ref": "#/components/schemas/PhoneNumber" } + }, + "additionalProperties": false +}, +"PersonWriteModelIPatchObject": { + "type": "object", + "properties": { + "name": { "maxLength": 255, "minLength": 3, "type": "string" }, + "dateOfBirth": { "type": "string", "format": "date-time" }, + "email": { "type": "string", "format": "email", "nullable": true }, + "phoneNumber": { "$ref": "#/components/schemas/PhoneNumber" } + }, + "additionalProperties": false, + "description": "Partial update of PersonWriteModel. Omitted properties are left unchanged." +} +``` + +`required` is gone, the read-only `display` is gone, validation constraints and the +`$ref` to the shared `PhoneNumber` component survive. + +After — .NET 10 `AddOpenApi` (OpenAPI 3.1): + +```json +"IPatchObjectOfPersonWriteModel": { + "type": "object", + "properties": { + "name": { "maxLength": 255, "minLength": 3, "type": "string" }, + "dateOfBirth": { "type": "string", "format": "date-time" }, + "email": { "type": ["null", "string"] } + }, + "description": "Partial update of PersonWriteModel. Omitted properties are left unchanged." +} +``` + +## 7. Swagger UI + +Swagger UI renders entirely from the emitted document, so it needs no integration of its +own — fixing the schema fixes the UI. Verified by driving the sample app's `/swagger` +page in a headless browser, before and after the filter. + +### Schema tab + +Today the PATCH request body renders as an empty model: + +``` +PersonWriteModelIPatchObject { +} +``` + +With the filter it renders the full model, including the description, the +`maxLength`/`minLength` constraints on `name`, `string($email)` and `nullable: true` on +`email`, and a link through to the shared `PhoneNumber` model. + +The detail that matters most for usability: `name` renders **without** the red `*` +required marker, while the PUT operation — built from the same `PersonWriteModel` — +still renders `name*`. A reader comparing the two operations in the UI can see at a +glance that PATCH accepts a subset. That is exactly the signal §8 argues for, and it +comes for free from clearing `required`. + +### Example Value tab and "Try it out" + +Today the example is `{}`, so "Try it out" prefills an empty body and the user has to +hand-write the JSON from knowledge the document does not contain. After the fix the +example is a real body. + +**One wrinkle, and it is worth planning for.** Swagger UI synthesizes its example from +`properties` and ignores `required`, so the generated PATCH example lists *every* +property: + +```json +{ + "name": "string", + "dateOfBirth": "2026-09-11T03:04:47.930Z", + "email": "user@example.com", + "phoneNumber": { "areaCode": "string", "number": "string" } +} +``` + +For a PUT that is correct. For a PATCH it reads as "send all of these", which is the +opposite of the point, and a user who clicks Try it out will send a full replacement. +Nothing in the schema can prevent that — it is how Swagger UI builds examples for any +object schema. + +The mitigation is to set `Example` on the patch schema, which Swagger UI honours over +its synthesized one. Verified: setting `target.Example` to a single-property object +makes both the Example Value tab and the Try it out prefill show just that property. + +This argues for a fourth option: + +```csharp +/// Supplies the example body for a patch schema. Return null to let the +/// UI synthesize one from the properties (which will list all of them). +public Func? Example { get; set; } +``` + +Recommendation: ship it **off by default**, because any single-property example the +library synthesizes has to pick a property arbitrarily, and a wrong-looking example is +worse than a verbose one. Document the one-liner prominently instead — this is the +first thing a consumer will want to tune, and it is the only part of the Swagger UI +experience the schema alone cannot get right. + +### Caveats + +* The above is the Swashbuckle path, which emits OpenAPI 3.0.4. +* The built-in .NET 9+ generator emits 3.1, and Swagger UI can be pointed at that + document instead. The bundle Swashbuckle 10.2.3 ships is 3.1-aware, so the same + rendering applies; an older pinned `swagger-ui` may not be. +* Redoc, Scalar, and generated clients read the same document, so they benefit + identically. None of them were tested. + +## 8. Semantics worth stating explicitly + +* **Omitted vs. null.** OpenAPI has no vocabulary for "absent means unchanged" beyond + leaving a property out of `required`; that is precisely what the transform produces. + The `description` carries the rest of the meaning for human readers. There is no + need for `nullable: true` on every property — that would wrongly tell clients they + may send `null` for a non-nullable member. +* **Nullability is inherited, not widened.** `name` stays non-nullable, so + `{"name": null}` remains invalid in the document, matching the runtime behaviour + where the `[Required]` validator runs whenever the property is present. +* **`additionalProperties: false` is inherited and is correct here** — a PATCH body + should still reject members the model does not define. +* **Inaccuracies in the source schema are inherited too.** The sample's `PhoneNumber` + serializes through a custom converter to the string `"(123)456-7890"`, but its schema + documents an object, because Swashbuckle cannot see through a `JsonConverter`. The + patch schema reproduces that, which is the right behaviour: fixing it once on the + write model (`o.MapType(() => new OpenApiSchema { Type = "string" })`) + fixes both PUT and PATCH. Worth calling out in the docs so it is not mistaken for a + SimplePatch bug. + +## 9. Open questions + +1. **Schema id.** `PersonWriteModelIPatchObject` (Swashbuckle) and + `IPatchObjectOfPersonWriteModel` (built-in) are both serviceable but leak the + interface name into generated client types. Should `AddSimplePatchSchemas` default + to renaming these to `PersonWriteModelPatch`? Renaming means wrapping the + `SchemaIdSelector` / `CreateSchemaReferenceId` in the consumer's options, which is a + little more intrusive than a filter. Recommendation: ship it off by default, + `options.SchemaId = t => t.Name + "Patch"` to enable. +2. **NSwag.** The same transform fits NSwag's `ISchemaProcessor`. Worth a third package + only if there is demand; the proposal does not include it. +3. **Source generator (longer term).** A generator that emits + `PersonWriteModelPatch : IPatchObject` at compile time would make + correct OpenAPI the default for anyone who declares the concrete type as the + parameter, and would also buy trimming/AOT support and remove Roslyn from the + runtime dependency set. The filter/transformer proposed here stays useful for + interface-typed parameters, so the two are complementary and this proposal does not + block that direction. + +## 10. Plan + +1. Core: shared `PatchClassBuilder` cache, public `TryGetPatchSourceType`, global + namespace fix (§4). Unit tests for each. +2. `PTrampert.SimplePatch.Swashbuckle` with `AddSimplePatchSchemas` and + `SimplePatchSchemaOptions`. Tests drive the filter over a real `SchemaRepository` + and assert the emitted schema, including the read-only and `[JsonIgnore]` cases, + and that `Example` reaches the emitted document when supplied. +3. `PTrampert.SimplePatch.OpenApi` for net9.0/net10.0, same tests against a + `WebApplicationFactory` fetching `/openapi/v1.json`. +4. Wire the sample app to the Swashbuckle package, add an end-to-end assertion on the + PATCH route's request body schema, and document the setup in + `docs/getting-started.md` — including the `Example` one-liner, since the Swagger UI + example is the one part of the experience the schema alone cannot get right (§7).