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).