Skip to content

Delivery API: Generate typed OpenAPI schemas per content type#22666

Merged
lauraneto merged 15 commits into
v18/devfrom
v18/feature/delivery-api-document-type-schema-generation
May 6, 2026
Merged

Delivery API: Generate typed OpenAPI schemas per content type#22666
lauraneto merged 15 commits into
v18/devfrom
v18/feature/delivery-api-document-type-schema-generation

Conversation

@lauraneto

@lauraneto lauraneto commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds optional content-type-aware schema generation to the Delivery API OpenAPI document. When enabled, the spec emits per-document-type, per-element-type, and per-media-type schemas wired into the existing IApi* polymorphic schemas via oneOf + discriminator. Composition relationships are reflected through allOf references on the corresponding *PropertiesModel.

The feature is opt-in via Umbraco:CMS:DeliveryApi:OpenApi:GenerateContentTypeSchemas (default false).

Schema shape

For each non-element document type, the spec gains {Alias}ContentResponseModel (top-level response with cultures), {Alias}ContentModel (nested form, used in property references), and {Alias}PropertiesModel (the property bag). Element types get {Alias}ElementModel + {Alias}PropertiesModel, and media types get {Alias}MediaWithCropsResponseModel, {Alias}MediaWithCropsModel, and {Alias}PropertiesModel.

The interface schemas (IApiContentResponseModel, IApiContentModel, IApiMediaWithCropsResponseModel, IApiMediaWithCropsModel) become unions over their typed variants with the discriminator on contentType / mediaType.

Compositions

A composing type's *PropertiesModel references each composition's *PropertiesModel via allOf. Composition types themselves appear as full content types in the polymorphic union.

Testing

For end-to-end verification, check out the throwaway companion branch v18/task/delivery-api-openapi-sample-content. It commits a pre-seeded SQLite database plus four TypeScript / .NET console projects under tests/clients (orval, hey-api, kiota, nswag) that generate clients from the running spec and exercise the typed shape. See tests/clients/README.md for the full setup.

Once on that branch (and after applying the connection-string + Delivery API config snippets from the README to your local appsettings.json):

  1. dotnet run --project src/Umbraco.Web.UI to start Umbraco at https://localhost:44339. The committed DB ships with sample document, element, media, and composition types so the spec is non-trivial.
  2. Open /umbraco/openapi/delivery.json and confirm each document type produces its {Alias}ContentResponseModel, {Alias}ContentModel, and {Alias}PropertiesModel (or the media equivalents), that IApiContentResponseModel lists every type in its oneOf and discriminator.mapping, and that the composing type's *PropertiesModel references the composition's *PropertiesModel via allOf.
  3. In tests/clients/orval, run npm install && npm start. The script regenerates the client from the live spec, builds it, and prints the data for the seeded test page. Successful compilation + typed property access is the primary signal — discriminating on contentType should narrow the union to TestPageContentResponseModel and expose the composition fields (sharedToggle, sharedString, sharedRadiobox, sharedRichText).
  4. In tests/clients/hey-api, run npm install && npm start. Same expectations as orval; this client uses @hey-api/openapi-ts with the bundled fetch client.
  5. In tests/clients/kiota, run dotnet run. The build downloads the spec, restores the kiota tool, and generates a C# client. Compiles and runs, but property bags collapse to AdditionalData (kiota loses field typing through our *PropertiesModel indirection — see the project README). This is a known kiota limitation, not a spec issue.
  6. In tests/clients/nswag, run dotnet run. Build is expected to fail with ~17 compile errors referencing Anonymous, MultinodeTreepicker, MediaPicker, etc. NSwag's generator does not handle our OpenAPI 3.1 polymorphic shape. The project documents the failure mode rather than ships a working client.

Finally, flip Umbraco:CMS:DeliveryApi:OpenApi:GenerateContentTypeSchemas to false, restart the web app, and confirm /umbraco/openapi/delivery.json falls back to the generic ApiContent[Response]Model / ApiMediaWithCrops[Response]Model shape with no per-type schemas.

Base automatically changed from v18/feature/microsoft-open-api-document-generation to v18/dev April 30, 2026 19:12
@lauraneto lauraneto force-pushed the v18/feature/delivery-api-document-type-schema-generation branch from 4f0931a to f97db75 Compare May 1, 2026 08:50
@lauraneto lauraneto marked this pull request as ready for review May 4, 2026 13:20
Copilot AI review requested due to automatic review settings May 4, 2026 13:20
@claude

claude Bot commented May 4, 2026

Copy link
Copy Markdown

Claude finished @lauraneto's task in 10m 26s —— View job


PR Review

Target: origin/v18/dev · Based on commit: 5bd22410
· Skipped: 0 noise files out of 17 total

Adds opt-in typed OpenAPI schema generation to the Delivery API: when GenerateContentTypeSchemas = true, the spec emits per-document-type/element-type/media-type schemas wired into the existing polymorphic IApi* interfaces via oneOf + discriminator, with composition reflected through allOf on *PropertiesModel.

  • Modified public API: ContentJsonTypeResolverBase.GetDerivedTypes (behavior changed); DeliveryApiSettings (new nested OpenApiSettings class + OpenApi property); IApiElement ([JsonDerivedType] attribute added)
  • Other changes: New GenerateContentTypeSchemas configuration opt-in (default false); the Delivery API OpenAPI schema for IApiMediaWithCropsResponseModel gains a $type discriminator even in the default (non-typed) mode, as a consequence of the GetDerivedTypes change.

Important

  • src/Umbraco.Infrastructure/Serialization/ContentJsonTypeResolverBase.cs:47 → see inline comment. Before this PR GetDerivedTypes returned []; now the base class returns ApiMediaWithCropsResponse/ApiMediaWithCrops for the corresponding interface types, so both DeliveryApiJsonTypeResolver and WebhookJsonTypeResolver now serialize IApiMediaWithCrops properties with a $type discriminator. WebhookJsonTypeResolver is used for webhook payloads; content items with media-picker properties will emit $type in webhook JSON — a runtime behavior change for webhook consumers that is not mentioned in the PR description. Consider scoping this to DeliveryApiJsonTypeResolver only (override in that class), or document the webhook change explicitly.

Suggestions

  • tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Delivery/OpenApi/ContentTypeSchemaTransformerTests.cs: Test method names don't follow the Can_/Cannot_ PascalCase underscore convention defined in the project's coding preferences (e.g. Constructor_Throws_When_TypeInfoResolver_Is_NullCannot_Construct_Without_TypeInfoResolver). Minor, but worth aligning for consistency.

Approved with Suggestions for improvement

Good to go, but please carefully consider the importance of the suggestions. The implementation is well-structured, the two-phase schema+document transformer design is sound, the circular-reference placeholder mechanism is clever and well-explained, and the test coverage (contract snapshots + unit tests for both transformer phases) is thorough.

Comment thread src/Umbraco.Infrastructure/Serialization/ContentJsonTypeResolverBase.cs Outdated
@claude claude Bot added the category/api label May 4, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an opt-in Delivery API OpenAPI mode that emits typed schemas per document, element, and media type instead of only the generic interface models.

Changes:

  • Adds a new GenerateContentTypeSchemas Delivery API OpenAPI setting and wires a new schema/document transformer behind it.
  • Generates typed *ResponseModel, *Model, and *PropertiesModel schemas, including oneOf/discriminator unions and composition allOf chains.
  • Expands unit/integration coverage with scenario-specific contract tests and updated OpenAPI snapshots for disabled/enabled modes.

Reviewed changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/Umbraco.Cms.Api.Delivery/Configuration/ConfigureUmbracoDeliveryApiOpenApiOptions.cs Registers the new transformer only when the OpenAPI flag is enabled.
src/Umbraco.Cms.Api.Delivery/OpenApi/Transformers/ContentTypeSchemaTransformer.cs Core implementation for typed schema generation and reference cleanup.
src/Umbraco.Core/Configuration/Models/DeliveryApiSettings.cs Adds the new nested OpenAPI configuration flag.
src/Umbraco.Core/Models/DeliveryApi/IApiElement.cs Marks element responses as a polymorphic Delivery API base type.
src/Umbraco.Infrastructure/Serialization/ContentJsonTypeResolverBase.cs Adds media derived-type resolution used during schema generation.
tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Delivery/OpenApi/ContentTypeSchemaTransformerTests.cs Unit tests for placeholder replacement and typed union generation.
tests/Umbraco.Tests.Integration/Umbraco.Api.Delivery/OpenApi/OpenApiContractTestBase.cs Shared OpenAPI assertions plus sample content/media type setup.
tests/Umbraco.Tests.Integration/Umbraco.Api.Delivery/OpenApi/OpenApiContractTest.Default.cs Verifies the disabled-flag baseline contract.
tests/Umbraco.Tests.Integration/Umbraco.Api.Delivery/OpenApi/OpenApiContractTest.GenericSchemasWithSampleTypes.cs Verifies generic schemas remain when sample types exist but the flag is off.
tests/Umbraco.Tests.Integration/Umbraco.Api.Delivery/OpenApi/OpenApiContractTest.TypedSchemasEmptyProject.cs Verifies enabled mode for built-in schemas in an empty project.
tests/Umbraco.Tests.Integration/Umbraco.Api.Delivery/OpenApi/OpenApiContractTest.TypedSchemasWithSampleTypes.cs Verifies enabled mode with sample document, element, media, and composition types.
tests/Umbraco.Tests.Integration/Umbraco.Api.Delivery/OpenApi/ExpectedContracts/default.json Snapshot for the default OpenAPI contract.
tests/Umbraco.Tests.Integration/Umbraco.Api.Delivery/OpenApi/ExpectedContracts/generic-schemas-with-sample-types.json Snapshot for generic mode with seeded types.
tests/Umbraco.Tests.Integration/Umbraco.Api.Delivery/OpenApi/ExpectedContracts/typed-schemas-empty-project.json Snapshot for typed mode without custom sample types.
tests/Umbraco.Tests.Integration/Umbraco.Api.Delivery/OpenApi/ExpectedContracts/typed-schemas-with-sample-types.json Snapshot for typed mode with seeded sample types.

Comment thread src/Umbraco.Infrastructure/Serialization/ContentJsonTypeResolverBase.cs Outdated
Comment thread src/Umbraco.Cms.Api.Delivery/OpenApi/Transformers/ContentTypeSchemaTransformer.cs Outdated
Comment thread src/Umbraco.Cms.Api.Delivery/OpenApi/Transformers/ContentTypeSchemaTransformer.cs Outdated
lauraneto added 4 commits May 4, 2026 18:42
ContentTypeSchemaTransformer now filters DocumentTypes through
DeliveryApiSettings.IsAllowedContentType so document types blocked
by AllowedContentTypeAliases / DisallowedContentTypeAliases no longer
leak into the polymorphic union or discriminator mapping.
ContentJsonTypeResolverBase.GetDerivedTypes goes back to returning
empty. Previously it registered ApiMediaWithCrops and
ApiMediaWithCropsResponse as derived types of their interfaces, which
made every consumer of the resolver (the Delivery API and webhooks)
emit a $type discriminator on media payloads, even when the typed
schema feature was disabled.

The Delivery API still needs a base schema for the typed media
schemas to extend via allOf. Since the concrete media classes are
internal to Umbraco.Infrastructure and cannot be referenced from
[JsonDerivedType] in Core, ContentTypeSchemaTransformer now builds
that base from the interface's own properties when the interface has
no [JsonDerivedType] entries. Content/element interfaces are
unaffected and keep using their declared concrete derived types.

Snapshots regenerated.
Removes the [JsonDerivedType] attributes from IApiContent,
IApiContentResponse, and IApiElement. Without them System.Text.Json
configures no polymorphism by default, so wire payloads stop carrying
$type fields and the OpenAPI spec stops emitting a discriminator on
the generic schemas - matching v17 Delivery API behaviour. Consumers
that need polymorphic serialization can still register derived types
via ContentJsonTypeResolverBase.

Snapshots regenerated.
…ry-api-document-type-schema-generation-clean
AndyButland

This comment was marked as off-topic.

@AndyButland AndyButland dismissed their stale review May 5, 2026 13:32

Spotted an auto-review comment or two to check out first.

@AndyButland

AndyButland commented May 5, 2026

Copy link
Copy Markdown
Contributor

Just to clear up any confusion @lauraneto - I've added comments for #22710 on this PR by accident. I haven't done anything yet to review this one.

lauraneto added 2 commits May 5, 2026 16:41
…hema-generation-clean

# Conflicts:
#	tests/Umbraco.Tests.Integration/Umbraco.Api.Delivery/OpenApi/ExpectedContracts/default.json
…ry-api-document-type-schema-generation-clean
@AndyButland AndyButland added the status/needs-docs Requires new or updated documentation label May 6, 2026
…component schema.

Avoid unnecessary re-get of the JsonTypeInfo for the default case.

@AndyButland AndyButland left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implementation and tests all look great here @lauraneto - you've done a very thorough job.

I think if you've done the testing you've indicated in your companion branch that's a good sign that the output is going to work as expected - so I don't think I need to repeat that.

What I've done though is generate the output of /umbraco/openapi/delivery.json and compared with the setting on and off. Found a few things - as you'll see, with Claude's help - to consider:

  1. I see IApiElementModel as:
      "IApiElementModel": {
        "required": [
          "contentType"
        ],
        "type": [
          "null",
          "object"
        ],

But IApiContentModel as:

      "IApiContentModel": {
        "required": [
          "contentType"
        ],
        "type": "object",

So the former nullable, but the latter not (meaning I believe block.content and block.settings would appear nullable when they aren't). Same issue for IRichTextElementModel. It seems to happen due to the order of updates, and having a nullable property pollute the referenced model to be nullable as well.

The transformer's CreateContentTypeProperties passed schema => schema.Type |= JsonSchemaType.Null as a callback into CreateSchema to mark each property
nullable, but CreateSchema invoked that callback against the same OpenApiSchema instance it then registered as a shared component (e.g. IApiElementModel) — so the null flag was OR-ed into the shared definition rather than the property-reference site, and every other consumer of that component inherited the nullability. Made worse by being order-dependent: whichever property happened to reach the type first decided whether its component got polluted.

  1. Most models have addtionalProperties: false, e.g.:
      "CategoryContentResponseModel": {
        "type": "object",
        "allOf": [
          {
            "$ref": "#/components/schemas/IApiContentResponseBaseModel"
          },
          {
            "$ref": "#/components/schemas/CategoryContentModel"
          }
        ],
        "additionalProperties": false
      },

Maybe we drop setting that? Here's Claude's view - you can determine better than me if it makes sense!

Under JSON Schema 2020-12 (which OpenAPI 3.1 mandates), here's what a strict validator does, in order:

  1. Validate against IApiContentResponseBaseModel (the allOf parent). ✓ — every required field is there.
  2. Validate against ArticleContentModel (the second allOf parent). ✓ — properties matches.
  3. Validate against the local schema. The local schema has properties: {} (empty) and additionalProperties: false.
  4. additionalProperties only sees properties declared in this schema's own properties keyword. It does not look through allOf. So id, createDate,
    updateDate, route, properties, cultures — every property contributed by an allOf parent — counts as "additional" → REJECTED.

This is the classic JSON Schema gotcha — additionalProperties predates allOf having well-defined merge semantics, so it never learned to look through composition. JSON Schema 2019-09 introduced unevaluatedProperties specifically to fix it.

Why your clients still work: Code generators don't validate documents — they read them. Every generator you tested (orval, hey-api, kiota) treats allOf as inheritance: it merges the parent's properties into the child's effective shape before applying additionalProperties. So in TypeScript-land, the article instance has all the inherited fields and additional-properties checking is a no-op against the merged shape. The constraint is silently ignored.

What you give up: the documentation no longer claims "no other properties allowed". What you actually had: a constraint that rejected every conforming response under strict validation, ignored by lenient consumers. So you give up nothing real and gain consistency.

What you gain: the document validates strictly, every consumer interprets it identically, and it accurately describes the contract Umbraco actually offers — Umbraco's API can grow new properties in non-major releases, and a closed schema would be a lie even if it worked.

  1. I get a few odd named models when we have runs of capital letters - e.g. SEocontrolsPropertiesModel, XMlsitemapContentModel. Looks like you've followed what models builder does here, which is consistent. But maybe this is more a problem for typed clients where the consumer (and consuming developers) are likely "further away" from Umbraco so will just wonder why this is.

Looks like this could be done in ContentTypeSchemaService, modifying:

    // Currently uses the same transformation as ModelsBuilder (UmbracoServices.GetClrName)
    private string GetContentTypeSchemaId(string contentTypeAlias) =>
        contentTypeAlias.ToCleanString(_shortStringHelper, CleanStringType.ConvertCase | CleanStringType.PascalCase);

To:

    // Content type aliases are already valid identifiers (camelCase or PascalCase) per Umbraco
    // validation, so we only need to uppercase the first character. This avoids
    // conversion tokenised by case boundaries, which mangles uppercase runs (e.g. "xMLSitemap"
    // would become "XMlsitemap"). Deliberately deviates from ModelsBuilder for OpenAPI readability.
    private static string GetContentTypeSchemaId(string contentTypeAlias)
        => contentTypeAlias.Length == 0
            ? contentTypeAlias
            : char.ToUpperInvariant(contentTypeAlias[0]) + contentTypeAlias[1..];

Note that I've pushed a fix for 1. and for an inline comment (if you see any issues, of course please revert; but it was easier this way so I could actually test out a resolution and propose the update). I imagine this will break your integration tests, so you will have to adjust for that if you do keep the update.


I added the needs-docs label as we should make sure to document the new setting.

Comment thread src/Umbraco.Cms.Api.Delivery/OpenApi/Transformers/ContentTypeSchemaTransformer.cs Outdated
lauraneto added 4 commits May 6, 2026 09:49
JSON Schema 2020-12 (mandated by OpenAPI 3.1) does not let additionalProperties look through allOf, so a strict validator rejects every inherited field on the composed *ResponseModel/*Model/*PropertiesModel schemas. Most code generators silently ignore it, but the document is technically invalid and the constraint would be a lie anyway since Umbraco can grow new properties in non-major releases.

Removed from all four schema construction sites (response, content type, properties, and the interface-based fallback) and regenerated the affected snapshots.
Replaces the legacy ModelsBuilder-style ToCleanString tokenizer with
ToFirstUpperInvariant. The tokenizer split aliases on case boundaries
and mangled capital-letter runs (e.g. "xMLSitemap" -> "XMlsitemap"),
making the typed schema names harder to read for OpenAPI consumers.
Since content type aliases are already valid identifiers, only the
first character needs uppercasing.

Also adds an "xMLSitemap" sample type to the integration tests to
cover the casing-preservation behavior.
Document, element, and media types share the same alias namespace
across content/media (a doc-type and a media-type can use the same
alias), so a "{Schema}PropertiesModel" naming scheme could collide.

Properties model schemas now follow the same Content/Element/Media
suffix as their parent *Model schema:

- Document type: ArticlePageContentPropertiesModel
- Element type:  TestElementElementPropertiesModel
- Media type:    VideoMediaPropertiesModel

Composition references look up each composition's own IsElement so
that a doc-type composing an element-type (allowed in the UI) still
references the correct ElementPropertiesModel schema.
@lauraneto lauraneto requested a review from AndyButland May 6, 2026 11:37

@AndyButland AndyButland left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The updates all look good to me @lauraneto and I don't see anything further to comment. Just note my mention from earlier about preparing a docs update for the new configuration setting please.

If you are happy, fine with me to merge in.

@lauraneto lauraneto enabled auto-merge (squash) May 6, 2026 11:45
@lauraneto lauraneto merged commit fec0cac into v18/dev May 6, 2026
26 of 27 checks passed
@lauraneto lauraneto deleted the v18/feature/delivery-api-document-type-schema-generation branch May 6, 2026 12:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants