From e17c724b4ecc53615a8bbd3f3b0d04dc90d3c5b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 02:58:11 +0000 Subject: [PATCH 01/14] (PATCH): Add proposal for OpenAPI contract generation for IPatchObject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routes taking [FromBody] IPatchObject are documented from the interface, which has no properties, so the request body currently emits as { "type": "object", "additionalProperties": false } — a body that strictly permits nothing. The proposal recommends deriving the patch schema from the source model's own schema (drop required, drop non-writable properties, add a description) rather than describing the generated Optional-valued class, and ships that as two small opt-in integration packages: an ISchemaFilter for Swashbuckle and an IOpenApiSchemaTransformer for the built-in .NET 9+ generator. Both approaches were prototyped and their output is recorded in the document. Also records the core-library prerequisites: a shared PatchClassBuilder cache, a public TryGetPatchSourceType helper, and a fix for PatchClassBuilder throwing CS1001 for source types declared in the global namespace. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RtsmxAyPuVHce2nkVDZh2v --- docs/proposals/openapi-contract-generation.md | 345 ++++++++++++++++++ 1 file changed, 345 insertions(+) create mode 100644 docs/proposals/openapi-contract-generation.md diff --git a/docs/proposals/openapi-contract-generation.md b/docs/proposals/openapi-contract-generation.md new file mode 100644 index 0000000..5d477a1 --- /dev/null +++ b/docs/proposals/openapi-contract-generation.md @@ -0,0 +1,345 @@ +# Proposal: OpenAPI contract generation for `IPatchObject` + +Status: proposed +Applies to: `PTrampert.SimplePatch` 1.x + +## 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: + +| Package | Target | Depends on | Hook | +| --- | --- | --- | --- | +| `PTrampert.SimplePatch.Swashbuckle` | net8.0 | `Swashbuckle.AspNetCore.SwaggerGen` | `ISchemaFilter` | +| `PTrampert.SimplePatch.OpenApi` | net9.0, net10.0 | `Microsoft.AspNetCore.OpenApi` | `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. + +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; +} +``` + +## 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.Shared` 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.Shared.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.Shared.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 should live in a small +shared internal helper that each package compiles as a linked source file, so the +behaviour cannot diverge. + +## 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. 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. + +## 8. 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. + +## 9. 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. +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`. From 429cb2a3ec482ada53e82a7908876379ab38fe1b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 03:08:45 +0000 Subject: [PATCH 02/14] (PATCH): Record verified Swagger UI behaviour in the OpenAPI proposal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swagger UI renders from the emitted document, so the proposed schema filter fixes it with no UI-specific work. Verified in a headless browser against the sample app: the request body goes from an empty model to the full model with constraints and description, and name renders without the required marker while the PUT body built from the same model still shows name*. The one thing the schema alone cannot fix is the Example Value tab. Swagger UI synthesizes examples from properties and ignores required, so a PATCH example lists every property and Try it out sends a full replacement. Setting Example on the patch schema overrides that, verified, so SimplePatchSchemaOptions gains an Example factory — off by default, since any property the library picked would be arbitrary. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RtsmxAyPuVHce2nkVDZh2v --- docs/proposals/openapi-contract-generation.md | 92 ++++++++++++++++++- 1 file changed, 87 insertions(+), 5 deletions(-) diff --git a/docs/proposals/openapi-contract-generation.md b/docs/proposals/openapi-contract-generation.md index 5d477a1..759518d 100644 --- a/docs/proposals/openapi-contract-generation.md +++ b/docs/proposals/openapi-contract-generation.md @@ -117,6 +117,10 @@ public sealed class SimplePatchSchemaOptions /// 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; } } ``` @@ -292,7 +296,83 @@ After — .NET 10 `AddOpenApi` (OpenAPI 3.1): } ``` -## 7. Semantics worth stating explicitly +## 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. @@ -312,7 +392,7 @@ After — .NET 10 `AddOpenApi` (OpenAPI 3.1): fixes both PUT and PATCH. Worth calling out in the docs so it is not mistaken for a SimplePatch bug. -## 8. Open questions +## 9. Open questions 1. **Schema id.** `PersonWriteModelIPatchObject` (Swashbuckle) and `IPatchObjectOfPersonWriteModel` (built-in) are both serviceable but leak the @@ -331,15 +411,17 @@ After — .NET 10 `AddOpenApi` (OpenAPI 3.1): interface-typed parameters, so the two are complementary and this proposal does not block that direction. -## 9. Plan +## 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 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`. + `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). From 8b0704472f15675c14dc8fb08e10fea0640b3de7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 03:39:46 +0000 Subject: [PATCH 03/14] (MINOR): Prepare the core package for OpenAPI integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Share the generated patch classes: the cache in PatchClassBuilder is now static, so PatchJsonConverterFactory and anything else that needs a patch type — the OpenAPI integrations, user code — resolve a source type to the same generated type instead of each compiling its own dynamic assembly for it. PatchClassBuilder.Shared is the instance to reach for. Make TypeExtensions public and add TryGetPatchSourceType, which answers the question an OpenAPI schema filter actually asks: it accepts the open interface as well as a concrete implementation. The two existing helpers stay internal. Fix patch class generation for source types in the global namespace, which failed with "error CS1001: Identifier expected" because the generated namespace came out as ".Optionals". Minimal-API apps declaring their models in Program.cs hit this immediately. Add SimplePatchSchemaOptions, the configuration both integration packages take. It lives here so they configure identically and can be referenced side by side. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RtsmxAyPuVHce2nkVDZh2v --- .../PatchClassBuilderTest.cs | 25 ++++++++++ .../TestObjects/GlobalNamespaceTestObject.cs | 6 +++ .../TypeExtensionsTest.cs | 44 ++++++++++++++++ PTrampert.SimplePatch/PatchClassBuilder.cs | 23 +++++++-- .../PatchJsonConverterFactory.cs | 4 +- .../SimplePatchSchemaOptions.cs | 50 +++++++++++++++++++ PTrampert.SimplePatch/TypeExtensions.cs | 47 ++++++++++++++--- 7 files changed, 185 insertions(+), 14 deletions(-) create mode 100644 PTrampert.SimplePatch.Test/TestObjects/GlobalNamespaceTestObject.cs create mode 100644 PTrampert.SimplePatch.Test/TypeExtensionsTest.cs create mode 100644 PTrampert.SimplePatch/SimplePatchSchemaOptions.cs diff --git a/PTrampert.SimplePatch.Test/PatchClassBuilderTest.cs b/PTrampert.SimplePatch.Test/PatchClassBuilderTest.cs index 9d824fa..6cce1f7 100644 --- a/PTrampert.SimplePatch.Test/PatchClassBuilderTest.cs +++ b/PTrampert.SimplePatch.Test/PatchClassBuilderTest.cs @@ -59,4 +59,29 @@ public void DynamicOptionalsClass_CanBeCreatedAndUsed() FakeStringProp = "FakeString:Fake Value" })); } + + [Test] + public void GetPatchClassFor_SharesGeneratedTypesAcrossBuilders() + { + var first = new PatchClassBuilder().GetPatchClassFor(typeof(OptionalsBuilderTestObject)); + var second = new PatchClassBuilder().GetPatchClassFor(typeof(OptionalsBuilderTestObject)); + + 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.Shared.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.Shared.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..dc6f9f0 --- /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.Shared.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/PatchClassBuilder.cs b/PTrampert.SimplePatch/PatchClassBuilder.cs index ce73f0d..a5fdce7 100644 --- a/PTrampert.SimplePatch/PatchClassBuilder.cs +++ b/PTrampert.SimplePatch/PatchClassBuilder.cs @@ -17,7 +17,23 @@ 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(); + + /// + /// A shared builder. Prefer this over constructing a new instance: all instances share + /// one cache, so the only thing a new instance costs is the allocation. + /// + public static PatchClassBuilder Shared { get; } = new(); /// /// Gets or creates a class that implements for the specified type. @@ -33,13 +49,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..cb2063b 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.Shared.GetPatchClassFor(baseObjectType); return (JsonConverter?)Activator.CreateInstance( typeof(PatchObjectJsonConverter<,>).MakeGenericType(concreteTypeToConvert, baseObjectType), BindingFlags.Public | BindingFlags.Instance, diff --git a/PTrampert.SimplePatch/SimplePatchSchemaOptions.cs b/PTrampert.SimplePatch/SimplePatchSchemaOptions.cs new file mode 100644 index 0000000..b1afd4f --- /dev/null +++ b/PTrampert.SimplePatch/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. + /// + /// + /// Worth setting. 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/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 +} From d74dc9bd305b062edcbcd709893c81b40523432e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 03:39:46 +0000 Subject: [PATCH 04/14] (MINOR): Add PTrampert.SimplePatch.Swashbuckle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swashbuckle documents a [FromBody] IPatchObject parameter from the interface, which has no properties, so the request body came out as { "type": "object", "additionalProperties": false } — a body that strictly permits nothing, which a validator or generated client will reject. AddSimplePatchSchemas registers a schema filter that rewrites that into the patched model's schema with every property optional. The schema is derived from the model's own generated schema rather than from the Optional-valued class PatchClassBuilder emits, so validation constraints, custom converters, property naming and any tuning already applied to the write model all carry over, and the PUT and PATCH contracts cannot describe different shapes. Properties the patch object cannot set — get-only ones, which the model's schema marks readOnly — are dropped. Their JSON names come from asking Swashbuckle to describe the generated patch class into a throwaway schema repository, so the names agree with whatever JsonSerializerOptions the application configured; Swashbuckle resolves those internally and exposes them to neither filters nor DI. The throwaway repository keeps the Optional schemas that produces out of the real document. The transform itself is a linked source file shared with the forthcoming built-in-OpenAPI package, so the two cannot drift. It is internal, so both packages can be referenced together. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RtsmxAyPuVHce2nkVDZh2v --- .../GlobalUsings.cs | 1 + ...ampert.SimplePatch.Swashbuckle.Test.csproj | 27 ++++ .../PatchObjectSchemaFilterTest.cs | 141 ++++++++++++++++++ .../TestObjects/PersonTestModel.cs | 26 ++++ .../PTrampert.SimplePatch.Swashbuckle.csproj | 36 +++++ .../PatchObjectSchemaFilter.cs | 69 +++++++++ .../SimplePatchSwaggerGenOptionsExtensions.cs | 49 ++++++ Shared/PatchSchemaTransform.cs | 64 ++++++++ 8 files changed, 413 insertions(+) create mode 100644 PTrampert.SimplePatch.Swashbuckle.Test/GlobalUsings.cs create mode 100644 PTrampert.SimplePatch.Swashbuckle.Test/PTrampert.SimplePatch.Swashbuckle.Test.csproj create mode 100644 PTrampert.SimplePatch.Swashbuckle.Test/PatchObjectSchemaFilterTest.cs create mode 100644 PTrampert.SimplePatch.Swashbuckle.Test/TestObjects/PersonTestModel.cs create mode 100644 PTrampert.SimplePatch.Swashbuckle/PTrampert.SimplePatch.Swashbuckle.csproj create mode 100644 PTrampert.SimplePatch.Swashbuckle/PatchObjectSchemaFilter.cs create mode 100644 PTrampert.SimplePatch.Swashbuckle/SimplePatchSwaggerGenOptionsExtensions.cs create mode 100644 Shared/PatchSchemaTransform.cs 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..459aef7 --- /dev/null +++ b/PTrampert.SimplePatch.Swashbuckle/PTrampert.SimplePatch.Swashbuckle.csproj @@ -0,0 +1,36 @@ + + + + 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..4275a67 --- /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.Shared.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/Shared/PatchSchemaTransform.cs b/Shared/PatchSchemaTransform.cs new file mode 100644 index 0000000..3347fe6 --- /dev/null +++ b/Shared/PatchSchemaTransform.cs @@ -0,0 +1,64 @@ +using Microsoft.OpenApi; + +namespace PTrampert.SimplePatch; + +/// +/// The patch-schema transform, shared by the Swashbuckle and built-in OpenAPI integrations as a +/// linked source file so the two cannot drift. It is internal, so each package gets its own copy +/// and referencing both packages stays unambiguous. +/// +/// +/// 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. +/// +internal 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; + } + } +} From a3f53ae0bdd25e2eec149beece85731287bc7974 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 03:39:46 +0000 Subject: [PATCH 05/14] (MINOR): Add PTrampert.SimplePatch.OpenApi for the built-in generator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same transform as the Swashbuckle package, as an IOpenApiSchemaTransformer, registered with AddOpenApi(options => options.AddSimplePatchSchemas()). Here the patchable property names come from JsonTypeInfo.Options — the very JsonSerializerOptions the document generator is using — so they match the names in the source model's schema exactly. net10.0 only. Obtaining the source model's schema from a transformer needs OpenApiSchemaTransformerContext.GetOrCreateSchemaAsync, which .NET 10 added and .NET 9 does not have. Swashbuckle covers .NET 8 and 9 in the meantime. Microsoft.AspNetCore.OpenApi is pinned to 10.0.12 rather than 10.0.1 because earlier 10.0.x pull Microsoft.OpenApi 2.0.0, which carries a known high severity advisory (GHSA-v5pm-xwqc-g5wc). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RtsmxAyPuVHce2nkVDZh2v --- .../GlobalUsings.cs | 1 + .../PTrampert.SimplePatch.OpenApi.Test.csproj | 31 ++++ .../PatchObjectSchemaTransformerTest.cs | 136 ++++++++++++++++++ .../TestObjects/PersonTestModel.cs | 26 ++++ .../PTrampert.SimplePatch.OpenApi.csproj | 42 ++++++ .../PatchObjectSchemaTransformer.cs | 59 ++++++++ .../SimplePatchOpenApiOptionsExtensions.cs | 48 +++++++ 7 files changed, 343 insertions(+) create mode 100644 PTrampert.SimplePatch.OpenApi.Test/GlobalUsings.cs create mode 100644 PTrampert.SimplePatch.OpenApi.Test/PTrampert.SimplePatch.OpenApi.Test.csproj create mode 100644 PTrampert.SimplePatch.OpenApi.Test/PatchObjectSchemaTransformerTest.cs create mode 100644 PTrampert.SimplePatch.OpenApi.Test/TestObjects/PersonTestModel.cs create mode 100644 PTrampert.SimplePatch.OpenApi/PTrampert.SimplePatch.OpenApi.csproj create mode 100644 PTrampert.SimplePatch.OpenApi/PatchObjectSchemaTransformer.cs create mode 100644 PTrampert.SimplePatch.OpenApi/SimplePatchOpenApiOptionsExtensions.cs 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..422196e --- /dev/null +++ b/PTrampert.SimplePatch.OpenApi/PTrampert.SimplePatch.OpenApi.csproj @@ -0,0 +1,42 @@ + + + + + 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..c22935b --- /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.Shared.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; + } +} From d74ee5b0b880d37954edadda6ddc173f7bfd36c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 03:39:46 +0000 Subject: [PATCH 06/14] (MINOR): Document the OpenAPI integrations and use one in the sample MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the sample app to PTrampert.SimplePatch.Swashbuckle, including an explicit example. That last part is worth showing: Swagger UI builds its example from the schema's properties and ignores "required", so the default PATCH example lists every property, which reads as "send all of these" and is what "Try it out" submits. Add the new projects to docfx's API metadata, and record in the proposal that it shipped, with the one deviation — net10.0 rather than net9.0 for the built-in generator. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RtsmxAyPuVHce2nkVDZh2v --- .../PTrampert.SimplePatch.Sample.csproj | 1 + PTrampert.SimplePatch.Sample/Program.cs | 13 ++- PTrampert.SimplePatch.sln | 87 +++++++++++++++++++ README.md | 80 +++++++++++++++++ docfx.json | 12 +++ docs/getting-started.md | 28 ++++++ docs/proposals/openapi-contract-generation.md | 9 +- 7 files changed, 228 insertions(+), 2 deletions(-) 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..c65cab6 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 => + { + // Worth setting: 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.sln b/PTrampert.SimplePatch.sln index 4ddf6f2..235a5e6 100644 --- a/PTrampert.SimplePatch.sln +++ b/PTrampert.SimplePatch.sln @@ -6,23 +6,110 @@ 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 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 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE EndGlobalSection EndGlobal diff --git a/README.md b/README.md index f61d76a..037b5d0 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 + +Worth doing. 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..92e1102 100644 --- a/docfx.json +++ b/docfx.json @@ -8,6 +8,18 @@ "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 d91587e..ed316fb 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 index 759518d..ffdcb1f 100644 --- a/docs/proposals/openapi-contract-generation.md +++ b/docs/proposals/openapi-contract-generation.md @@ -1,8 +1,15 @@ # Proposal: OpenAPI contract generation for `IPatchObject` -Status: proposed +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 From 65cb5113f833e461002fe7f1ad5e6dfeb835776f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 15:02:33 +0000 Subject: [PATCH 07/14] (MINOR): Share the patch schema transform by project reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback: don't link source files across csproj files. PatchSchemaTransform moves into PTrampert.SimplePatch, which both integration packages already reference, and the items are gone along with the Shared/ folder. It stays internal via InternalsVisibleTo rather than becoming public API, since it is an implementation detail of the integration packages. This costs the core package a Microsoft.OpenApi dependency, pinned to 2.12.0 — 2.3.0 and below carry GHSA-v5pm-xwqc-g5wc. Swashbuckle 10.2.3 declares 2.3.0 but runs fine against 2.12.0; the Swashbuckle tests exercise the real schema generator and still pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RtsmxAyPuVHce2nkVDZh2v --- .../PTrampert.SimplePatch.OpenApi.csproj | 5 ----- .../PTrampert.SimplePatch.Swashbuckle.csproj | 5 ----- PTrampert.SimplePatch/AssemblyInfo.cs | 6 ++++++ PTrampert.SimplePatch/PTrampert.SimplePatch.csproj | 2 ++ .../PatchSchemaTransform.cs | 6 +++--- docs/proposals/openapi-contract-generation.md | 14 +++++++++++--- 6 files changed, 22 insertions(+), 16 deletions(-) create mode 100644 PTrampert.SimplePatch/AssemblyInfo.cs rename {Shared => PTrampert.SimplePatch}/PatchSchemaTransform.cs (94%) diff --git a/PTrampert.SimplePatch.OpenApi/PTrampert.SimplePatch.OpenApi.csproj b/PTrampert.SimplePatch.OpenApi/PTrampert.SimplePatch.OpenApi.csproj index 422196e..7921638 100644 --- a/PTrampert.SimplePatch.OpenApi/PTrampert.SimplePatch.OpenApi.csproj +++ b/PTrampert.SimplePatch.OpenApi/PTrampert.SimplePatch.OpenApi.csproj @@ -25,11 +25,6 @@ - - - - - diff --git a/PTrampert.SimplePatch.Swashbuckle/PTrampert.SimplePatch.Swashbuckle.csproj b/PTrampert.SimplePatch.Swashbuckle/PTrampert.SimplePatch.Swashbuckle.csproj index 459aef7..b01b2bb 100644 --- a/PTrampert.SimplePatch.Swashbuckle/PTrampert.SimplePatch.Swashbuckle.csproj +++ b/PTrampert.SimplePatch.Swashbuckle/PTrampert.SimplePatch.Swashbuckle.csproj @@ -20,11 +20,6 @@ - - - - - diff --git a/PTrampert.SimplePatch/AssemblyInfo.cs b/PTrampert.SimplePatch/AssemblyInfo.cs new file mode 100644 index 0000000..cf5b6c8 --- /dev/null +++ b/PTrampert.SimplePatch/AssemblyInfo.cs @@ -0,0 +1,6 @@ +using System.Runtime.CompilerServices; + +// PatchSchemaTransform is shared with the OpenAPI integration packages, which reference this one. +// It stays internal: it is an implementation detail of those packages, not public API here. +[assembly: InternalsVisibleTo("PTrampert.SimplePatch.Swashbuckle")] +[assembly: InternalsVisibleTo("PTrampert.SimplePatch.OpenApi")] diff --git a/PTrampert.SimplePatch/PTrampert.SimplePatch.csproj b/PTrampert.SimplePatch/PTrampert.SimplePatch.csproj index b5ee4ca..4de07f1 100644 --- a/PTrampert.SimplePatch/PTrampert.SimplePatch.csproj +++ b/PTrampert.SimplePatch/PTrampert.SimplePatch.csproj @@ -22,6 +22,8 @@ + + diff --git a/Shared/PatchSchemaTransform.cs b/PTrampert.SimplePatch/PatchSchemaTransform.cs similarity index 94% rename from Shared/PatchSchemaTransform.cs rename to PTrampert.SimplePatch/PatchSchemaTransform.cs index 3347fe6..9694450 100644 --- a/Shared/PatchSchemaTransform.cs +++ b/PTrampert.SimplePatch/PatchSchemaTransform.cs @@ -3,9 +3,9 @@ namespace PTrampert.SimplePatch; /// -/// The patch-schema transform, shared by the Swashbuckle and built-in OpenAPI integrations as a -/// linked source file so the two cannot drift. It is internal, so each package gets its own copy -/// and referencing both packages stays unambiguous. +/// The patch-schema transform, shared by the Swashbuckle and built-in OpenAPI integration packages +/// — which reference this one — so that the two cannot drift. Internal rather than public: it is an +/// implementation detail of those packages. /// /// /// The patch contract for a model is the model's own contract with optional members, so the diff --git a/docs/proposals/openapi-contract-generation.md b/docs/proposals/openapi-contract-generation.md index ffdcb1f..efc678d 100644 --- a/docs/proposals/openapi-contract-generation.md +++ b/docs/proposals/openapi-contract-generation.md @@ -83,7 +83,7 @@ is respected. ## 3. Packaging -Keep the core package free of any OpenAPI dependency and ship two integration packages: +Ship two integration packages, each referencing the core package: | Package | Target | Depends on | Hook | | --- | --- | --- | --- | @@ -99,6 +99,12 @@ supports — recommendation is to target Swashbuckle 10 only, matching the sampl Both integration packages are small (roughly 50 lines each), so duplicating the transform logic between them costs less than straddling the API break. +The shared transform lives in the core package, which both integration packages reference. +That costs the core package a `Microsoft.OpenApi` dependency, paid by every consumer +whether or not they generate OpenAPI. The alternative — a third package holding just the +transform — keeps the core package dependency-free at the cost of one more thing to +version and publish. + Consumers opt in with a single call: ```csharp @@ -243,8 +249,10 @@ public sealed class PatchObjectSchemaTransformer(SimplePatchSchemaOptions option 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 should live in a small -shared internal helper that each package compiles as a linked source file, so the -behaviour cannot diverge. +internal helper in the core package, which both integration packages reference, so the +behaviour cannot diverge. (An earlier draft proposed compiling it into each package as a +linked source file; sharing through a project reference is the repository's convention, +and it costs the core package a `Microsoft.OpenApi` dependency.) ## 6. Verified output From 6fc5fc530338fbd09f42b465a17f8a9dd9029d07 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 15:09:53 +0000 Subject: [PATCH 08/14] (MINOR): Keep Microsoft.OpenApi out of the core package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback: the core project should not carry that reference. It was only there because PatchSchemaTransform had moved in, and the transform takes Microsoft.OpenApi types — so hosting it in the core package handed a Microsoft.OpenApi dependency to every consumer of PTrampert.SimplePatch, OpenAPI user or not. Each integration package now carries its own copy of the transform as a private method: about a dozen mechanical lines, kept in step by the two mirrored test suites. No linked source files, and the core package's dependency list is back to what it was before. What the integrations do share through their project reference to the core package is SimplePatchSchemaOptions, which needs nothing beyond System.Text.Json. The Swashbuckle package now pins Microsoft.OpenApi 2.12.0 itself, since Swashbuckle pulls 2.3.0 and that carries GHSA-v5pm-xwqc-g5wc. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RtsmxAyPuVHce2nkVDZh2v --- .../PatchObjectSchemaTransformer.cs | 37 ++++++++++- .../PTrampert.SimplePatch.Swashbuckle.csproj | 2 + .../PatchObjectSchemaFilter.cs | 37 ++++++++++- PTrampert.SimplePatch/AssemblyInfo.cs | 6 -- .../PTrampert.SimplePatch.csproj | 2 - PTrampert.SimplePatch/PatchSchemaTransform.cs | 64 ------------------- docs/proposals/openapi-contract-generation.md | 25 ++++---- 7 files changed, 88 insertions(+), 85 deletions(-) delete mode 100644 PTrampert.SimplePatch/AssemblyInfo.cs delete mode 100644 PTrampert.SimplePatch/PatchSchemaTransform.cs diff --git a/PTrampert.SimplePatch.OpenApi/PatchObjectSchemaTransformer.cs b/PTrampert.SimplePatch.OpenApi/PatchObjectSchemaTransformer.cs index c22935b..478e0a5 100644 --- a/PTrampert.SimplePatch.OpenApi/PatchObjectSchemaTransformer.cs +++ b/PTrampert.SimplePatch.OpenApi/PatchObjectSchemaTransformer.cs @@ -36,7 +36,7 @@ public async Task TransformAsync( var source = await context.GetOrCreateSchemaAsync(sourceType, cancellationToken: cancellationToken); - PatchSchemaTransform.Apply( + ApplyPatchSemantics( schema, source, sourceType, @@ -44,6 +44,41 @@ public async Task TransformAsync( options); } + // The mirror of this method lives in PTrampert.SimplePatch.Swashbuckle's schema filter. It cannot be + // shared through the core package without giving every consumer of PTrampert.SimplePatch a + // Microsoft.OpenApi dependency, so the two are kept in step by mirrored test suites instead. + private static void ApplyPatchSemantics( + 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; + } + } + private static HashSet GetPatchablePropertyNames(Type sourceType, OpenApiSchemaTransformerContext context) => PatchablePropertyNamesBySourceType.GetOrAdd(sourceType, _ => { diff --git a/PTrampert.SimplePatch.Swashbuckle/PTrampert.SimplePatch.Swashbuckle.csproj b/PTrampert.SimplePatch.Swashbuckle/PTrampert.SimplePatch.Swashbuckle.csproj index b01b2bb..636bc39 100644 --- a/PTrampert.SimplePatch.Swashbuckle/PTrampert.SimplePatch.Swashbuckle.csproj +++ b/PTrampert.SimplePatch.Swashbuckle/PTrampert.SimplePatch.Swashbuckle.csproj @@ -22,6 +22,8 @@ + + diff --git a/PTrampert.SimplePatch.Swashbuckle/PatchObjectSchemaFilter.cs b/PTrampert.SimplePatch.Swashbuckle/PatchObjectSchemaFilter.cs index 4275a67..bbc80bb 100644 --- a/PTrampert.SimplePatch.Swashbuckle/PatchObjectSchemaFilter.cs +++ b/PTrampert.SimplePatch.Swashbuckle/PatchObjectSchemaFilter.cs @@ -41,7 +41,7 @@ public void Apply(IOpenApiSchema schema, SchemaFilterContext context) return; } - PatchSchemaTransform.Apply( + ApplyPatchSemantics( target, source, sourceType, @@ -49,6 +49,41 @@ public void Apply(IOpenApiSchema schema, SchemaFilterContext context) options); } + // The mirror of this method lives in PTrampert.SimplePatch.OpenApi's transformer. It cannot be + // shared through the core package without giving every consumer of PTrampert.SimplePatch a + // Microsoft.OpenApi dependency, so the two are kept in step by mirrored test suites instead. + private static void ApplyPatchSemantics( + 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; + } + } + private static HashSet GetPatchablePropertyNames(Type sourceType, SchemaFilterContext context) => PatchablePropertyNamesBySourceType.GetOrAdd(sourceType, _ => { diff --git a/PTrampert.SimplePatch/AssemblyInfo.cs b/PTrampert.SimplePatch/AssemblyInfo.cs deleted file mode 100644 index cf5b6c8..0000000 --- a/PTrampert.SimplePatch/AssemblyInfo.cs +++ /dev/null @@ -1,6 +0,0 @@ -using System.Runtime.CompilerServices; - -// PatchSchemaTransform is shared with the OpenAPI integration packages, which reference this one. -// It stays internal: it is an implementation detail of those packages, not public API here. -[assembly: InternalsVisibleTo("PTrampert.SimplePatch.Swashbuckle")] -[assembly: InternalsVisibleTo("PTrampert.SimplePatch.OpenApi")] diff --git a/PTrampert.SimplePatch/PTrampert.SimplePatch.csproj b/PTrampert.SimplePatch/PTrampert.SimplePatch.csproj index 4de07f1..b5ee4ca 100644 --- a/PTrampert.SimplePatch/PTrampert.SimplePatch.csproj +++ b/PTrampert.SimplePatch/PTrampert.SimplePatch.csproj @@ -22,8 +22,6 @@ - - diff --git a/PTrampert.SimplePatch/PatchSchemaTransform.cs b/PTrampert.SimplePatch/PatchSchemaTransform.cs deleted file mode 100644 index 9694450..0000000 --- a/PTrampert.SimplePatch/PatchSchemaTransform.cs +++ /dev/null @@ -1,64 +0,0 @@ -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. Internal rather than public: it is an -/// implementation detail of those packages. -/// -/// -/// 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. -/// -internal 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/docs/proposals/openapi-contract-generation.md b/docs/proposals/openapi-contract-generation.md index efc678d..21c0dfe 100644 --- a/docs/proposals/openapi-contract-generation.md +++ b/docs/proposals/openapi-contract-generation.md @@ -83,7 +83,8 @@ is respected. ## 3. Packaging -Ship two integration packages, each referencing the core package: +Keep the core package free of any OpenAPI dependency and ship two integration packages, +each referencing it: | Package | Target | Depends on | Hook | | --- | --- | --- | --- | @@ -99,11 +100,14 @@ supports — recommendation is to target Swashbuckle 10 only, matching the sampl Both integration packages are small (roughly 50 lines each), so duplicating the transform logic between them costs less than straddling the API break. -The shared transform lives in the core package, which both integration packages reference. -That costs the core package a `Microsoft.OpenApi` dependency, paid by every consumer -whether or not they generate OpenAPI. The alternative — a third package holding just the -transform — keeps the core package dependency-free at the cost of one more thing to -version and publish. +What the integration packages share through that reference is `SimplePatchSchemaOptions`, +which needs nothing but `System.Text.Json`. The transform itself cannot be shared the same +way: it takes `Microsoft.OpenApi` types, so hosting it in the core package would hand a +`Microsoft.OpenApi` dependency to every consumer of `PTrampert.SimplePatch`, OpenAPI user +or not. It is therefore duplicated — about a dozen mechanical lines in each package, kept +in step by mirrored test suites. A third package holding just the transform would restore +the single copy, at the cost of one more thing to version and publish; not worth it at this +size. Consumers opt in with a single call: @@ -248,11 +252,10 @@ public sealed class PatchObjectSchemaTransformer(SimplePatchSchemaOptions option ``` 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 should live in a small -internal helper in the core package, which both integration packages reference, so the -behaviour cannot diverge. (An earlier draft proposed compiling it into each package as a -linked source file; sharing through a project reference is the repository's convention, -and it costs the core package a `Microsoft.OpenApi` dependency.) +model's mutability rules; the transform itself is identical, and each package carries its +own copy as a private method — see §3 for why it is not shared through the core package. +Their test suites mirror each other so that a change to one copy and not the other shows +up as a failure. ## 6. Verified output From 76a46e935af2ea29781d113a345af3d06d5d42d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 15:16:13 +0000 Subject: [PATCH 09/14] (MINOR): Move the shared schema code into PTrampert.SimplePatch.Schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review decision: rather than duplicating the transform across the two integration packages, it gets its own package. PatchSchemaTransform and SimplePatchSchemaOptions move there, and both integration packages reference it, so there is one copy of the transform and the core package's dependency list stays as it was. PatchSchemaTransform becomes public — it is now this package's surface rather than an implementation detail behind InternalsVisibleTo. The package targets net8.0 so that both consumers can take it: the Swashbuckle integration targets net8.0 and the built-in one targets net10.0. Resulting graph, from the packed nuspecs: PTrampert.SimplePatch Microsoft.CodeAnalysis.CSharp, System.CodeDom, System.Text.Json, DotNetCompilerPlatform ...Schema + Microsoft.OpenApi 2.12.0 ...Swashbuckle + Swashbuckle.AspNetCore.SwaggerGen ...OpenApi + Microsoft.AspNetCore.OpenApi Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RtsmxAyPuVHce2nkVDZh2v --- .../PTrampert.SimplePatch.OpenApi.csproj | 1 + .../PatchObjectSchemaTransformer.cs | 37 +---------- .../PTrampert.SimplePatch.Schema.csproj | 36 +++++++++++ .../PatchSchemaTransform.cs | 63 +++++++++++++++++++ .../SimplePatchSchemaOptions.cs | 0 .../PTrampert.SimplePatch.Swashbuckle.csproj | 5 +- .../PatchObjectSchemaFilter.cs | 37 +---------- PTrampert.SimplePatch.sln | 14 +++++ docfx.json | 6 ++ docs/proposals/openapi-contract-generation.md | 30 +++++---- 10 files changed, 140 insertions(+), 89 deletions(-) create mode 100644 PTrampert.SimplePatch.Schema/PTrampert.SimplePatch.Schema.csproj create mode 100644 PTrampert.SimplePatch.Schema/PatchSchemaTransform.cs rename {PTrampert.SimplePatch => PTrampert.SimplePatch.Schema}/SimplePatchSchemaOptions.cs (100%) diff --git a/PTrampert.SimplePatch.OpenApi/PTrampert.SimplePatch.OpenApi.csproj b/PTrampert.SimplePatch.OpenApi/PTrampert.SimplePatch.OpenApi.csproj index 7921638..9cc227b 100644 --- a/PTrampert.SimplePatch.OpenApi/PTrampert.SimplePatch.OpenApi.csproj +++ b/PTrampert.SimplePatch.OpenApi/PTrampert.SimplePatch.OpenApi.csproj @@ -32,6 +32,7 @@ + diff --git a/PTrampert.SimplePatch.OpenApi/PatchObjectSchemaTransformer.cs b/PTrampert.SimplePatch.OpenApi/PatchObjectSchemaTransformer.cs index 478e0a5..c22935b 100644 --- a/PTrampert.SimplePatch.OpenApi/PatchObjectSchemaTransformer.cs +++ b/PTrampert.SimplePatch.OpenApi/PatchObjectSchemaTransformer.cs @@ -36,7 +36,7 @@ public async Task TransformAsync( var source = await context.GetOrCreateSchemaAsync(sourceType, cancellationToken: cancellationToken); - ApplyPatchSemantics( + PatchSchemaTransform.Apply( schema, source, sourceType, @@ -44,41 +44,6 @@ public async Task TransformAsync( options); } - // The mirror of this method lives in PTrampert.SimplePatch.Swashbuckle's schema filter. It cannot be - // shared through the core package without giving every consumer of PTrampert.SimplePatch a - // Microsoft.OpenApi dependency, so the two are kept in step by mirrored test suites instead. - private static void ApplyPatchSemantics( - 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; - } - } - private static HashSet GetPatchablePropertyNames(Type sourceType, OpenApiSchemaTransformerContext context) => PatchablePropertyNamesBySourceType.GetOrAdd(sourceType, _ => { 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/SimplePatchSchemaOptions.cs b/PTrampert.SimplePatch.Schema/SimplePatchSchemaOptions.cs similarity index 100% rename from PTrampert.SimplePatch/SimplePatchSchemaOptions.cs rename to PTrampert.SimplePatch.Schema/SimplePatchSchemaOptions.cs diff --git a/PTrampert.SimplePatch.Swashbuckle/PTrampert.SimplePatch.Swashbuckle.csproj b/PTrampert.SimplePatch.Swashbuckle/PTrampert.SimplePatch.Swashbuckle.csproj index 636bc39..745cc72 100644 --- a/PTrampert.SimplePatch.Swashbuckle/PTrampert.SimplePatch.Swashbuckle.csproj +++ b/PTrampert.SimplePatch.Swashbuckle/PTrampert.SimplePatch.Swashbuckle.csproj @@ -22,12 +22,15 @@ - + + diff --git a/PTrampert.SimplePatch.Swashbuckle/PatchObjectSchemaFilter.cs b/PTrampert.SimplePatch.Swashbuckle/PatchObjectSchemaFilter.cs index bbc80bb..4275a67 100644 --- a/PTrampert.SimplePatch.Swashbuckle/PatchObjectSchemaFilter.cs +++ b/PTrampert.SimplePatch.Swashbuckle/PatchObjectSchemaFilter.cs @@ -41,7 +41,7 @@ public void Apply(IOpenApiSchema schema, SchemaFilterContext context) return; } - ApplyPatchSemantics( + PatchSchemaTransform.Apply( target, source, sourceType, @@ -49,41 +49,6 @@ public void Apply(IOpenApiSchema schema, SchemaFilterContext context) options); } - // The mirror of this method lives in PTrampert.SimplePatch.OpenApi's transformer. It cannot be - // shared through the core package without giving every consumer of PTrampert.SimplePatch a - // Microsoft.OpenApi dependency, so the two are kept in step by mirrored test suites instead. - private static void ApplyPatchSemantics( - 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; - } - } - private static HashSet GetPatchablePropertyNames(Type sourceType, SchemaFilterContext context) => PatchablePropertyNamesBySourceType.GetOrAdd(sourceType, _ => { diff --git a/PTrampert.SimplePatch.sln b/PTrampert.SimplePatch.sln index 235a5e6..04a7853 100644 --- a/PTrampert.SimplePatch.sln +++ b/PTrampert.SimplePatch.sln @@ -14,6 +14,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PTrampert.SimplePatch.Swash 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 @@ -108,6 +110,18 @@ Global {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 diff --git a/docfx.json b/docfx.json index 92e1102..d8777b2 100644 --- a/docfx.json +++ b/docfx.json @@ -9,6 +9,12 @@ "**/*.csproj" ] }, + { + "src": "PTrampert.SimplePatch.Schema", + "files": [ + "**/*.csproj" + ] + }, { "src": "PTrampert.SimplePatch.Swashbuckle", "files": [ diff --git a/docs/proposals/openapi-contract-generation.md b/docs/proposals/openapi-contract-generation.md index 21c0dfe..7780d9b 100644 --- a/docs/proposals/openapi-contract-generation.md +++ b/docs/proposals/openapi-contract-generation.md @@ -83,13 +83,14 @@ is respected. ## 3. Packaging -Keep the core package free of any OpenAPI dependency and ship two integration packages, -each referencing it: +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.Swashbuckle` | net8.0 | `Swashbuckle.AspNetCore.SwaggerGen` | `ISchemaFilter` | -| `PTrampert.SimplePatch.OpenApi` | net9.0, net10.0 | `Microsoft.AspNetCore.OpenApi` | `IOpenApiSchemaTransformer` | +| `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`, @@ -100,14 +101,13 @@ supports — recommendation is to target Swashbuckle 10 only, matching the sampl Both integration packages are small (roughly 50 lines each), so duplicating the transform logic between them costs less than straddling the API break. -What the integration packages share through that reference is `SimplePatchSchemaOptions`, -which needs nothing but `System.Text.Json`. The transform itself cannot be shared the same -way: it takes `Microsoft.OpenApi` types, so hosting it in the core package would hand a -`Microsoft.OpenApi` dependency to every consumer of `PTrampert.SimplePatch`, OpenAPI user -or not. It is therefore duplicated — about a dozen mechanical lines in each package, kept -in step by mirrored test suites. A third package holding just the transform would restore -the single copy, at the cost of one more thing to version and publish; not worth it at this -size. +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: @@ -252,10 +252,8 @@ public sealed class PatchObjectSchemaTransformer(SimplePatchSchemaOptions option ``` 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 each package carries its -own copy as a private method — see §3 for why it is not shared through the core package. -Their test suites mirror each other so that a change to one copy and not the other shows -up as a failure. +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 From 88f83b7a5194598cce9ccfeff5db65303681c4a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 04:02:45 +0000 Subject: [PATCH 10/14] (PATCH): Drop the "Worth doing" editorializing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on the README. The same construction appeared twice more in this PR — "Worth setting" in the sample's comment and in the SimplePatchSchemaOptions remarks — so all three go, and the prose is reflowed. The substance the reader needs is the Swagger UI behaviour itself, which is unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RtsmxAyPuVHce2nkVDZh2v --- PTrampert.SimplePatch.Sample/Program.cs | 6 +++--- .../SimplePatchSchemaOptions.cs | 10 +++++----- README.md | 6 +++--- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/PTrampert.SimplePatch.Sample/Program.cs b/PTrampert.SimplePatch.Sample/Program.cs index c65cab6..47b708a 100644 --- a/PTrampert.SimplePatch.Sample/Program.cs +++ b/PTrampert.SimplePatch.Sample/Program.cs @@ -18,9 +18,9 @@ // Document IPatchObject request bodies with the patched model's schema. opts.AddSimplePatchSchemas(patch => { - // Worth setting: 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. + // 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" }; }); }); diff --git a/PTrampert.SimplePatch.Schema/SimplePatchSchemaOptions.cs b/PTrampert.SimplePatch.Schema/SimplePatchSchemaOptions.cs index b1afd4f..453ffb2 100644 --- a/PTrampert.SimplePatch.Schema/SimplePatchSchemaOptions.cs +++ b/PTrampert.SimplePatch.Schema/SimplePatchSchemaOptions.cs @@ -40,11 +40,11 @@ public class SimplePatchSchemaOptions /// default — to let the UI synthesize one. /// /// - /// Worth setting. 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. + /// 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/README.md b/README.md index 037b5d0..0c53ddc 100644 --- a/README.md +++ b/README.md @@ -142,9 +142,9 @@ two can never describe different shapes. ### Setting an example -Worth doing. 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: +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 => From fc5eb81934947b12dfa497e4b551b03f8bd21cd9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 04:17:53 +0000 Subject: [PATCH 11/14] (PATCH): Pin Microsoft.OpenApi in one place The Swashbuckle integration carried its own Microsoft.OpenApi 2.12.0 pin alongside the schema package's. Once the transform moved into PTrampert.SimplePatch.Schema, that became redundant: the schema package's direct reference already outranks the 2.3.0 that Swashbuckle.AspNetCore brings, so the vulnerable version never resolves. Verified both sides. In this repo, restoring the Swashbuckle project without the pin resolves Microsoft.OpenApi 2.12.0 and raises no NU1903. For consumers, a scratch project referencing only PTrampert.SimplePatch.Swashbuckle from a local feed resolves 2.12.0 the same way, so the floor still holds through the package graph. One pin, in the package that actually uses the types. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RtsmxAyPuVHce2nkVDZh2v --- .../PTrampert.SimplePatch.Swashbuckle.csproj | 4 ---- 1 file changed, 4 deletions(-) diff --git a/PTrampert.SimplePatch.Swashbuckle/PTrampert.SimplePatch.Swashbuckle.csproj b/PTrampert.SimplePatch.Swashbuckle/PTrampert.SimplePatch.Swashbuckle.csproj index 745cc72..d0a96c4 100644 --- a/PTrampert.SimplePatch.Swashbuckle/PTrampert.SimplePatch.Swashbuckle.csproj +++ b/PTrampert.SimplePatch.Swashbuckle/PTrampert.SimplePatch.Swashbuckle.csproj @@ -22,10 +22,6 @@ - - From ea9bd58db4f53f8d24b259d2c98aebbe1689a77f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 04:44:13 +0000 Subject: [PATCH 12/14] (MINOR): Rename PatchClassBuilder.Shared to Instance, hide the constructor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback. The singleton is now PatchClassBuilder.Instance, and the constructor is internal, so consumers reach the builder only through it — verified: a scratch project referencing the package fails to compile "new PatchClassBuilder()" while PatchClassBuilder.Instance builds fine. The tests do construct builders directly, to prove separate instances still resolve a source type to the same generated patch type, so the core assembly grants InternalsVisibleTo to PTrampert.SimplePatch.Test. The proposal document's snippets named the old member; updated so they match the shipped API. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RtsmxAyPuVHce2nkVDZh2v --- .../PatchObjectSchemaTransformer.cs | 2 +- .../PatchObjectSchemaFilter.cs | 2 +- PTrampert.SimplePatch.Test/PatchClassBuilderTest.cs | 4 ++-- PTrampert.SimplePatch.Test/TypeExtensionsTest.cs | 2 +- PTrampert.SimplePatch/AssemblyInfo.cs | 6 ++++++ PTrampert.SimplePatch/PatchClassBuilder.cs | 10 +++++++--- PTrampert.SimplePatch/PatchJsonConverterFactory.cs | 2 +- docs/proposals/openapi-contract-generation.md | 6 +++--- 8 files changed, 22 insertions(+), 12 deletions(-) create mode 100644 PTrampert.SimplePatch/AssemblyInfo.cs diff --git a/PTrampert.SimplePatch.OpenApi/PatchObjectSchemaTransformer.cs b/PTrampert.SimplePatch.OpenApi/PatchObjectSchemaTransformer.cs index c22935b..3017ea3 100644 --- a/PTrampert.SimplePatch.OpenApi/PatchObjectSchemaTransformer.cs +++ b/PTrampert.SimplePatch.OpenApi/PatchObjectSchemaTransformer.cs @@ -50,7 +50,7 @@ private static HashSet GetPatchablePropertyNames(Type sourceType, OpenAp // 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.Shared.GetPatchClassFor(sourceType); + var patchType = PatchClassBuilder.Instance.GetPatchClassFor(sourceType); return context.JsonTypeInfo.Options .GetTypeInfo(patchType).Properties .Select(property => property.Name) diff --git a/PTrampert.SimplePatch.Swashbuckle/PatchObjectSchemaFilter.cs b/PTrampert.SimplePatch.Swashbuckle/PatchObjectSchemaFilter.cs index 4275a67..1991099 100644 --- a/PTrampert.SimplePatch.Swashbuckle/PatchObjectSchemaFilter.cs +++ b/PTrampert.SimplePatch.Swashbuckle/PatchObjectSchemaFilter.cs @@ -56,7 +56,7 @@ private static HashSet GetPatchablePropertyNames(Type sourceType, Schema // 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.Shared.GetPatchClassFor(sourceType); + 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) ?? []; diff --git a/PTrampert.SimplePatch.Test/PatchClassBuilderTest.cs b/PTrampert.SimplePatch.Test/PatchClassBuilderTest.cs index 6cce1f7..1b0a84b 100644 --- a/PTrampert.SimplePatch.Test/PatchClassBuilderTest.cs +++ b/PTrampert.SimplePatch.Test/PatchClassBuilderTest.cs @@ -70,7 +70,7 @@ public void GetPatchClassFor_SharesGeneratedTypesAcrossBuilders() { 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.Shared.GetPatchClassFor(typeof(OptionalsBuilderTestObject)), Is.SameAs(first)); + Assert.That(PatchClassBuilder.Instance.GetPatchClassFor(typeof(OptionalsBuilderTestObject)), Is.SameAs(first)); })); } @@ -80,7 +80,7 @@ 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.Shared.GetPatchClassFor(globalNamespaceType); + var patchType = PatchClassBuilder.Instance.GetPatchClassFor(globalNamespaceType); Assert.That(patchType.GetProperty(nameof(GlobalNamespaceTestObject.Name)), Is.Not.Null); } diff --git a/PTrampert.SimplePatch.Test/TypeExtensionsTest.cs b/PTrampert.SimplePatch.Test/TypeExtensionsTest.cs index dc6f9f0..19eeb87 100644 --- a/PTrampert.SimplePatch.Test/TypeExtensionsTest.cs +++ b/PTrampert.SimplePatch.Test/TypeExtensionsTest.cs @@ -19,7 +19,7 @@ public void TryGetPatchSourceType_ResolvesTheOpenInterface() [Test] public void TryGetPatchSourceType_ResolvesAGeneratedPatchClass() { - var patchType = PatchClassBuilder.Shared.GetPatchClassFor(typeof(OptionalsBuilderTestObject)); + var patchType = PatchClassBuilder.Instance.GetPatchClassFor(typeof(OptionalsBuilderTestObject)); var result = patchType.TryGetPatchSourceType(out var sourceType); diff --git a/PTrampert.SimplePatch/AssemblyInfo.cs b/PTrampert.SimplePatch/AssemblyInfo.cs new file mode 100644 index 0000000..f0f6d21 --- /dev/null +++ b/PTrampert.SimplePatch/AssemblyInfo.cs @@ -0,0 +1,6 @@ +using System.Runtime.CompilerServices; + +// PatchClassBuilder's constructor is internal — PatchClassBuilder.Instance is the only builder +// consumers need. The tests construct builders directly to prove that separate instances still +// share one cache. +[assembly: InternalsVisibleTo("PTrampert.SimplePatch.Test")] diff --git a/PTrampert.SimplePatch/PatchClassBuilder.cs b/PTrampert.SimplePatch/PatchClassBuilder.cs index a5fdce7..537ee84 100644 --- a/PTrampert.SimplePatch/PatchClassBuilder.cs +++ b/PTrampert.SimplePatch/PatchClassBuilder.cs @@ -30,10 +30,14 @@ public class PatchClassBuilder private static readonly ConcurrentDictionary OptionalsClasses = new(); /// - /// A shared builder. Prefer this over constructing a new instance: all instances share - /// one cache, so the only thing a new instance costs is the allocation. + /// The builder. Every instance resolves a source type to the same generated patch type, so + /// there is nothing to gain from having more than one — hence the internal constructor. /// - public static PatchClassBuilder Shared { get; } = new(); + public static PatchClassBuilder Instance { get; } = new(); + + internal PatchClassBuilder() + { + } /// /// Gets or creates a class that implements for the specified type. diff --git a/PTrampert.SimplePatch/PatchJsonConverterFactory.cs b/PTrampert.SimplePatch/PatchJsonConverterFactory.cs index cb2063b..92863fa 100644 --- a/PTrampert.SimplePatch/PatchJsonConverterFactory.cs +++ b/PTrampert.SimplePatch/PatchJsonConverterFactory.cs @@ -28,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.Shared.GetPatchClassFor(baseObjectType); + var concreteTypeToConvert = PatchClassBuilder.Instance.GetPatchClassFor(baseObjectType); return (JsonConverter?)Activator.CreateInstance( typeof(PatchObjectJsonConverter<,>).MakeGenericType(concreteTypeToConvert, baseObjectType), BindingFlags.Public | BindingFlags.Instance, diff --git a/docs/proposals/openapi-contract-generation.md b/docs/proposals/openapi-contract-generation.md index 7780d9b..4fc9f21 100644 --- a/docs/proposals/openapi-contract-generation.md +++ b/docs/proposals/openapi-contract-generation.md @@ -149,7 +149,7 @@ These are prerequisites, not nice-to-haves. `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.Shared` singleton + `_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` @@ -193,7 +193,7 @@ public sealed class PatchObjectSchemaFilter( var generated = context.SchemaGenerator.GenerateSchema(sourceType, context.SchemaRepository); if (Resolve(generated, context.SchemaRepository) is not { } source) return; - var patchType = PatchClassBuilder.Shared.GetPatchClassFor(sourceType); + var patchType = PatchClassBuilder.Instance.GetPatchClassFor(sourceType); var patchable = jsonOptions.Value.JsonSerializerOptions .GetTypeInfo(patchType).Properties .Select(p => p.Name) @@ -236,7 +236,7 @@ public sealed class PatchObjectSchemaTransformer(SimplePatchSchemaOptions option if (!context.JsonTypeInfo.Type.TryGetPatchSourceType(out var sourceType)) return; var source = await context.GetOrCreateSchemaAsync(sourceType, cancellationToken: cancellationToken); - var patchType = PatchClassBuilder.Shared.GetPatchClassFor(sourceType); + var patchType = PatchClassBuilder.Instance.GetPatchClassFor(sourceType); var patchable = /* same JsonTypeInfo lookup as above */; schema.Type = source.Type; From 7416069557d99013bead1c0ee9e20ab78b2f01e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 04:51:41 +0000 Subject: [PATCH 13/14] (MINOR): Keep PatchClassBuilder's constructor public MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backs out the internal constructor from ea9bd58, keeping only the rename to PatchClassBuilder.Instance. PatchClassBuilder is a public type that has shipped with an implicit public constructor, so hiding it removes a member consumers could be calling — a breaking change, and this release is not a major one. The rename itself is safe: Shared was introduced in this branch and never shipped. InternalsVisibleTo goes with it; the tests construct builders through the public constructor again. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RtsmxAyPuVHce2nkVDZh2v --- PTrampert.SimplePatch/AssemblyInfo.cs | 6 ------ PTrampert.SimplePatch/PatchClassBuilder.cs | 8 ++------ 2 files changed, 2 insertions(+), 12 deletions(-) delete mode 100644 PTrampert.SimplePatch/AssemblyInfo.cs diff --git a/PTrampert.SimplePatch/AssemblyInfo.cs b/PTrampert.SimplePatch/AssemblyInfo.cs deleted file mode 100644 index f0f6d21..0000000 --- a/PTrampert.SimplePatch/AssemblyInfo.cs +++ /dev/null @@ -1,6 +0,0 @@ -using System.Runtime.CompilerServices; - -// PatchClassBuilder's constructor is internal — PatchClassBuilder.Instance is the only builder -// consumers need. The tests construct builders directly to prove that separate instances still -// share one cache. -[assembly: InternalsVisibleTo("PTrampert.SimplePatch.Test")] diff --git a/PTrampert.SimplePatch/PatchClassBuilder.cs b/PTrampert.SimplePatch/PatchClassBuilder.cs index 537ee84..d261bea 100644 --- a/PTrampert.SimplePatch/PatchClassBuilder.cs +++ b/PTrampert.SimplePatch/PatchClassBuilder.cs @@ -30,15 +30,11 @@ public class PatchClassBuilder private static readonly ConcurrentDictionary OptionalsClasses = new(); /// - /// The builder. Every instance resolves a source type to the same generated patch type, so - /// there is nothing to gain from having more than one — hence the internal constructor. + /// The builder. Prefer this over constructing a new instance: all instances share one cache, + /// so the only thing a new instance costs is the allocation. /// public static PatchClassBuilder Instance { get; } = new(); - internal PatchClassBuilder() - { - } - /// /// Gets or creates a class that implements for the specified type. /// This class will have properties for each writable property of the type, wrapped in . From baeb33351c2815c91d0ecddec48c18e740f4b629 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 04:54:14 +0000 Subject: [PATCH 14/14] (MINOR): Mark PatchClassBuilder's constructor obsolete Points callers at PatchClassBuilder.Instance and says the constructor will be made internal in the next major version, linking #75 which tracks that. Still a warning, not an error: a consumer calling new PatchClassBuilder() compiles, with CS0618. Two tests that only needed a builder now use Instance; GetPatchClassFor_SharesGeneratedTypesAcrossBuilders keeps the constructor behind a scoped pragma, since separately constructed builders sharing one cache is exactly what it asserts. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RtsmxAyPuVHce2nkVDZh2v --- .../PatchClassBuilderTest.cs | 11 ++++++----- PTrampert.SimplePatch/PatchClassBuilder.cs | 17 +++++++++++++++-- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/PTrampert.SimplePatch.Test/PatchClassBuilderTest.cs b/PTrampert.SimplePatch.Test/PatchClassBuilderTest.cs index 1b0a84b..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; @@ -63,8 +60,12 @@ public void DynamicOptionalsClass_CanBeCreatedAndUsed() [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)(() => { diff --git a/PTrampert.SimplePatch/PatchClassBuilder.cs b/PTrampert.SimplePatch/PatchClassBuilder.cs index d261bea..363100f 100644 --- a/PTrampert.SimplePatch/PatchClassBuilder.cs +++ b/PTrampert.SimplePatch/PatchClassBuilder.cs @@ -30,10 +30,23 @@ public class PatchClassBuilder private static readonly ConcurrentDictionary OptionalsClasses = new(); /// - /// The builder. Prefer this over constructing a new instance: all instances share one cache, - /// so the only thing a new instance costs is the allocation. + /// 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.