Apply per-field annotations on $ref, not on referenced schema (#5187)#5189
Apply per-field annotations on $ref, not on referenced schema (#5187)#5189
Conversation
Per-field annotations such as `@deprecated` or `@description` on a product field whose type is a named case class were leaking into the referenced component definition (and being suppressed on the `$ref`) when the type was referenced only once, or identically at each usage. Derivation now preserves the canonical schema via the `Schema.OriginalForDocs` attribute, so the documentation interpreters attach the annotation to the `$ref` rather than the component itself. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Fixes an OpenAPI/AsyncAPI documentation generation bug where per-field annotations (e.g. @deprecated, @description) applied to a product field of a named schema were being stored on the referenced component schema instead of the $ref, causing tools like Swagger UI to miss the field-level metadata.
Changes:
- Add
Schema.OriginalForDocsattribute to preserve a canonical (un-enriched) schema when field annotations enrich a named schema during derivation. - Update
ToKeyedSchemasto store the canonical schema inkeyToSchemawhile still allowing$refenrichment at usage sites. - Add regression coverage: a new OpenAPI YAML verification test + updated generic derivation expectations.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| json/pickler/src/main/scala/sttp/tapir/json/pickler/SchemaDerivation.scala | Stashes original schema in an attribute when field annotations enrich a named schema (Scala 3 pickler derivation). |
| core/src/main/scala-3/sttp/tapir/generic/auto/SchemaMagnoliaDerivation.scala | Applies OriginalForDocs in Scala 3 Magnolia derivation for product fields. |
| core/src/main/scala-2/sttp/tapir/generic/auto/SchemaMagnoliaDerivation.scala | Applies OriginalForDocs in Scala 2 Magnolia derivation for product fields. |
| core/src/main/scala/sttp/tapir/Schema.scala | Introduces the Schema.OriginalForDocs attribute type/key. |
| docs/apispec-docs/src/main/scala/sttp/tapir/docs/apispec/schema/ToKeyedSchemas.scala | Uses the canonical schema (when present) when populating keyed component schemas. |
| docs/openapi-docs/src/test/scalajvm/.../VerifyYamlMultiCustomiseSchemaTest.scala | Adds regression test for deprecated nested case class field when referenced type is only used once. |
| docs/openapi-docs/src/test/resources/.../expected_deprecated_only_field.yml | Adds expected YAML output verifying deprecated: true appears on the $ref property. |
| core/src/test/scala/sttp/tapir/generic/SchemaGenericAutoTest.scala | Updates expectations to include the new OriginalForDocs attribute on enriched field schemas. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // #5187: for a product field whose type is a named case class with per-field annotations (e.g. @deprecated), | ||
| // derivation preserves the canonical (un-enriched) form via Schema.OriginalForDocs so that those annotations | ||
| // don't leak into the referenced component definition. ToSchemaReference.map still observes the difference | ||
| // between the canonical and the field-enriched schema, and attaches the annotations to the $ref. | ||
| val storeSchema = schema.attribute(TSchema.OriginalForDocs.Attribute).map(_.schema).getOrElse(schema) | ||
| val thisSchema = SchemaKey(schema).map(_ -> storeSchema).toList |
There was a problem hiding this comment.
storeSchema uses OriginalForDocs to populate keyToSchema with the un-enriched schema. This avoids leaking per-field metadata into components, but it also drops any per-field changes that aren't re-applied in ToSchemaReference.map (e.g. schema.format, validators/constraints, docsExtensions, or arbitrary @Schema.annotations.customise changes). As TSchemaToASchema doesn't add these for $ref schemas, such per-field annotations will silently disappear from the generated docs. Consider either (a) extending ToSchemaReference.map to propagate the additional supported fields (at least format, and possibly constraints/docsExtensions), or (b) only using OriginalForDocs when the enrichment affects properties that can be represented alongside a $ref (description/default/example/deprecated/title/nullable).
Summary
Fixes #5187.
@deprecated(and other per-field annotations like@description) on a product field whose type is a named case class were leaking into the referenced component definition instead of appearing on the$ref— so Swagger UI didn't render the field as deprecated.Root cause: derivation applies field annotations to the field's typeclass, which for a case class is the canonical
Schema.ToKeyedSchemasthen stored that field-enriched schema as the canonical form inkeyToSchema; becauseoriginalSchema == schemaat the reference site,ToSchemaReference.mapsuppressed the annotation on the$refwhile it lived on the component.combine()already handled the multi-usage divergent case; this was only broken when the type was referenced once or identically at every usage.Fix: when field annotations modify a named schema, derivation stashes the canonical copy via a new
Schema.OriginalForDocsattribute.ToKeyedSchemasuses that canonical copy when populatingkeyToSchema, soToSchemaReference.mapsees the real difference and attaches the annotation to the$ref.Test plan
VerifyYamlMultiCustomiseSchemaTestreproducing [BUG] @deprecated annotation ignored on nested case class fields in Swagger UI #5187SchemaGenericAutoTestexpectations for the new attributecore,apispecDocs,openapiDocs,asyncapiDocs,picklerJson,openapiVerifiertest suites green on Scala 2.13 and Scala 3🤖 Generated with Claude Code