Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,20 @@ trait SchemaMagnoliaDerivation {
SProduct(
ctx.parameters.map { p =>
val annotations = mergeAnnotations(p.annotations, p.inheritedAnnotations)
val pSchema = enrichSchema(p.typeclass, annotations)
val pSchema = withOriginalForDocs(p.typeclass, enrichSchema(p.typeclass, annotations))
val encodedName = getEncodedName(annotations).getOrElse(genericDerivationConfig.toEncodedName(p.label))

SProductField[T, p.PType](FieldName(p.label, encodedName), pSchema, t => Some(p.dereference(t)))
}.toList
)

// #5187: if field-level annotations changed the named schema, preserve the canonical version so that
// documentation interpreters can use it for the referenced component definition.
private def withOriginalForDocs[X](original: Schema[X], enriched: Schema[X]): Schema[X] =
if ((enriched ne original) && original.name.isDefined)
enriched.attribute(Schema.OriginalForDocs.Attribute, Schema.OriginalForDocs(original))
else enriched

private def typeNameToSchemaName(typeName: TypeName, annotations: Seq[Any]): Schema.SName = {
def allTypeArguments(tn: TypeName): Seq[TypeName] = tn.typeArguments.flatMap(tn2 => tn2 +: allTypeArguments(tn2))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,20 @@ trait SchemaMagnoliaDerivation {
SProduct(
ctx.params.map { p =>
val annotations = mergeAnnotations(p.annotations, p.inheritedAnnotations)
val pSchema = enrichSchema(p.typeclass, annotations)
val pSchema = withOriginalForDocs(p.typeclass, enrichSchema(p.typeclass, annotations))
val encodedName = getEncodedName(annotations).getOrElse(genericDerivationConfig.toEncodedName(p.label))

SProductField[T, p.PType](FieldName(p.label, encodedName), pSchema, t => Some(p.deref(t)))
}.toList
)

// #5187: if field-level annotations changed the named schema, preserve the canonical version so that
// documentation interpreters can use it for the referenced component definition.
private def withOriginalForDocs[X](original: Schema[X], enriched: Schema[X]): Schema[X] =
if ((enriched ne original) && original.name.isDefined)
enriched.attribute(Schema.OriginalForDocs.Attribute, Schema.OriginalForDocs(original))
else enriched

private def typeNameToSchemaName(typeName: TypeInfo, annotations: Seq[Any]): Schema.SName = {
def allTypeArguments(tn: TypeInfo): Seq[TypeInfo] = tn.typeParams.toList.flatMap(tn2 => tn2 +: allTypeArguments(tn2))

Expand Down
9 changes: 9 additions & 0 deletions core/src/main/scala/sttp/tapir/Schema.scala
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,15 @@ object Schema extends LowPrioritySchema with SchemaCompanionMacros {
new AttributeKey[EncodedDiscriminatorValue]("sttp.tapir.Schema.EncodedDiscriminatorValue")
}

/** Captures the canonical (un-field-enriched) form of a named schema that was used as a product field together with per-usage
* annotations (e.g. `@deprecated`, `@description`). Documentation interpreters use this reference when building component
* definitions to avoid leaking field-level customisations into the referenced type (#5187). Set only by schema derivation.
*/
case class OriginalForDocs(schema: Schema[_])
object OriginalForDocs {
val Attribute: AttributeKey[OriginalForDocs] = new AttributeKey[OriginalForDocs]("sttp.tapir.Schema.OriginalForDocs")
}

/** @param typeParameterShortNames
* full name of type parameters, name is legacy and kept only for backward compatibility
*/
Expand Down
22 changes: 13 additions & 9 deletions core/src/test/scala/sttp/tapir/generic/SchemaGenericAutoTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,15 @@ class SchemaGenericAutoTest extends AsyncFlatSpec with Matchers {

it should "add meta-data to schema from annotations" in {
val schema = implicitly[Schema[I]]
val canonicalK = Schema[K](
SProduct(
List(
field(FieldName("double"), implicitly[Schema[Double]].format("double64")),
field(FieldName("str"), stringSchema.format("special-string"))
)
),
Some(SName("sttp.tapir.generic.K"))
)
schema shouldBe Schema[I](
SProduct(
List(
Expand All @@ -147,15 +156,10 @@ class SchemaGenericAutoTest extends AsyncFlatSpec with Matchers {
),
field(
FieldName("child", "child-k-name"),
Schema[K](
SProduct(
List(
field(FieldName("double"), implicitly[Schema[Double]].format("double64")),
field(FieldName("str"), stringSchema.format("special-string"))
)
),
Some(SName("sttp.tapir.generic.K"))
).deprecated(true).description("child-k-desc")
canonicalK
.deprecated(true)
.description("child-k-desc")
.attribute(Schema.OriginalForDocs.Attribute, Schema.OriginalForDocs(canonicalK))
)
)
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ private[docs] object ToKeyedSchemas {
def apply[T](codec: Codec[_, T, _]): List[KeyedSchema] = apply(codec.schema)

def apply(schema: TSchema[_]): List[KeyedSchema] = {
val thisSchema = SchemaKey(schema).map(_ -> schema).toList
// #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
Comment on lines +10 to +15
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

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

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

Copilot uses AI. Check for mistakes.
val nestedSchemas = schema match {
case TSchema(TSchemaType.SArray(o), _, _, _, _, _, _, _, _, _, _) => apply(o)
case t @ TSchema(o: TSchemaType.SOption[_, _], _, _, _, _, _, _, _, _, _, _) =>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
openapi: 3.1.0
info:
title: Entities
version: '1.0'
paths:
/:
get:
operationId: getRoot
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/HasOnlyDeprecatedReference'
required: true
responses:
'200':
description: ''
'400':
description: 'Invalid value for: body'
content:
text/plain:
schema:
type: string
components:
schemas:
Data1:
title: Data1
type: object
required:
- x
properties:
x:
type: string
HasOnlyDeprecatedReference:
title: HasOnlyDeprecatedReference
type: object
required:
- field1
- field2
properties:
field1:
type: string
field2:
$ref: '#/components/schemas/Data1'
deprecated: true
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,16 @@ class VerifyYamlMultiCustomiseSchemaTest extends AnyFunSuite with Matchers {
val actualYamlNoIndent = noIndentation(actualYaml)
actualYamlNoIndent shouldBe expectedYaml
}

test("deprecated nested case class field, when referenced case class is not used elsewhere (#5187)") {
val expectedYaml = load("multi_customise_schema/expected_deprecated_only_field.yml")
val actualYaml = OpenAPIDocsInterpreter()
.toOpenAPI(endpoint.in(jsonBody[HasOnlyDeprecatedReference]), Info("Entities", "1.0"))
.toYaml

val actualYamlNoIndent = noIndentation(actualYaml)
actualYamlNoIndent shouldBe expectedYaml
}
}

object VerifyYamlMultiCustomiseSchemaTest {
Expand All @@ -73,4 +83,5 @@ object VerifyYamlMultiCustomiseSchemaTest {

case class HasOptionalDeprecated(field1: Data1, @Schema.annotations.deprecated field2: Option[Data1])
case class HasCollectionDeprecated(field1: List[Data1], @Schema.annotations.deprecated field2: List[Data1])
case class HasOnlyDeprecatedReference(field1: String, @Schema.annotations.deprecated field2: Data1)
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,20 @@ private class SchemaDerivation(genericDerivationConfig: Expr[Configuration])(usi
case '[f] =>
val fieldSchema: Expr[Schema[f]] = '{ $childSchemasArray(${ Expr(i) }).asInstanceOf[Schema[f]] }
val enrichedFieldSchema = enrichSchema(fieldSchema, fieldAnnotations)
// #5187: preserve the canonical schema so the docs interpreters don't leak per-field annotations
// into the referenced component definition.
val fieldSchemaWithOriginal = '{
val original = $fieldSchema
val enriched = $enrichedFieldSchema
if ((enriched ne original) && original.name.isDefined)
enriched.attribute(Schema.OriginalForDocs.Attribute, Schema.OriginalForDocs(original))
else enriched
}

'{
SProductField(
FieldName($name, $encodedName),
$enrichedFieldSchema,
$fieldSchemaWithOriginal,
obj => Some(${ Select('{ obj }.asTerm, fieldSymbol).asExprOf[f] })
)
}
Expand Down
Loading