From 3ec18aba36e4f6bc1096f1df3afdf84921623fe8 Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Wed, 22 Apr 2026 08:11:32 +0000 Subject: [PATCH] Apply per-field annotations on $ref, not on referenced schema (#5187) 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) --- .../auto/SchemaMagnoliaDerivation.scala | 9 +++- .../auto/SchemaMagnoliaDerivation.scala | 9 +++- core/src/main/scala/sttp/tapir/Schema.scala | 9 ++++ .../tapir/generic/SchemaGenericAutoTest.scala | 22 +++++---- .../docs/apispec/schema/ToKeyedSchemas.scala | 7 ++- .../expected_deprecated_only_field.yml | 45 +++++++++++++++++++ .../VerifyYamlMultiCustomiseSchemaTest.scala | 11 +++++ .../tapir/json/pickler/SchemaDerivation.scala | 11 ++++- 8 files changed, 110 insertions(+), 13 deletions(-) create mode 100644 docs/openapi-docs/src/test/resources/multi_customise_schema/expected_deprecated_only_field.yml diff --git a/core/src/main/scala-2/sttp/tapir/generic/auto/SchemaMagnoliaDerivation.scala b/core/src/main/scala-2/sttp/tapir/generic/auto/SchemaMagnoliaDerivation.scala index 4e5a6cc9f1..6b6c9a1c17 100644 --- a/core/src/main/scala-2/sttp/tapir/generic/auto/SchemaMagnoliaDerivation.scala +++ b/core/src/main/scala-2/sttp/tapir/generic/auto/SchemaMagnoliaDerivation.scala @@ -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)) diff --git a/core/src/main/scala-3/sttp/tapir/generic/auto/SchemaMagnoliaDerivation.scala b/core/src/main/scala-3/sttp/tapir/generic/auto/SchemaMagnoliaDerivation.scala index 92bbe5efe2..070dbb4c4a 100644 --- a/core/src/main/scala-3/sttp/tapir/generic/auto/SchemaMagnoliaDerivation.scala +++ b/core/src/main/scala-3/sttp/tapir/generic/auto/SchemaMagnoliaDerivation.scala @@ -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)) diff --git a/core/src/main/scala/sttp/tapir/Schema.scala b/core/src/main/scala/sttp/tapir/Schema.scala index 1dc49adecd..21a9b78826 100644 --- a/core/src/main/scala/sttp/tapir/Schema.scala +++ b/core/src/main/scala/sttp/tapir/Schema.scala @@ -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 */ diff --git a/core/src/test/scala/sttp/tapir/generic/SchemaGenericAutoTest.scala b/core/src/test/scala/sttp/tapir/generic/SchemaGenericAutoTest.scala index e5aa722fe3..47052630a9 100644 --- a/core/src/test/scala/sttp/tapir/generic/SchemaGenericAutoTest.scala +++ b/core/src/test/scala/sttp/tapir/generic/SchemaGenericAutoTest.scala @@ -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( @@ -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)) ) ) ), diff --git a/docs/apispec-docs/src/main/scala/sttp/tapir/docs/apispec/schema/ToKeyedSchemas.scala b/docs/apispec-docs/src/main/scala/sttp/tapir/docs/apispec/schema/ToKeyedSchemas.scala index 1abf0c10af..e379a33cbc 100644 --- a/docs/apispec-docs/src/main/scala/sttp/tapir/docs/apispec/schema/ToKeyedSchemas.scala +++ b/docs/apispec-docs/src/main/scala/sttp/tapir/docs/apispec/schema/ToKeyedSchemas.scala @@ -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 val nestedSchemas = schema match { case TSchema(TSchemaType.SArray(o), _, _, _, _, _, _, _, _, _, _) => apply(o) case t @ TSchema(o: TSchemaType.SOption[_, _], _, _, _, _, _, _, _, _, _, _) => diff --git a/docs/openapi-docs/src/test/resources/multi_customise_schema/expected_deprecated_only_field.yml b/docs/openapi-docs/src/test/resources/multi_customise_schema/expected_deprecated_only_field.yml new file mode 100644 index 0000000000..5b545164ed --- /dev/null +++ b/docs/openapi-docs/src/test/resources/multi_customise_schema/expected_deprecated_only_field.yml @@ -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 diff --git a/docs/openapi-docs/src/test/scalajvm/sttp/tapir/docs/openapi/VerifyYamlMultiCustomiseSchemaTest.scala b/docs/openapi-docs/src/test/scalajvm/sttp/tapir/docs/openapi/VerifyYamlMultiCustomiseSchemaTest.scala index bdc56c44e8..d7452dffef 100644 --- a/docs/openapi-docs/src/test/scalajvm/sttp/tapir/docs/openapi/VerifyYamlMultiCustomiseSchemaTest.scala +++ b/docs/openapi-docs/src/test/scalajvm/sttp/tapir/docs/openapi/VerifyYamlMultiCustomiseSchemaTest.scala @@ -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 { @@ -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) } diff --git a/json/pickler/src/main/scala/sttp/tapir/json/pickler/SchemaDerivation.scala b/json/pickler/src/main/scala/sttp/tapir/json/pickler/SchemaDerivation.scala index 6351519711..9fa2389c4c 100644 --- a/json/pickler/src/main/scala/sttp/tapir/json/pickler/SchemaDerivation.scala +++ b/json/pickler/src/main/scala/sttp/tapir/json/pickler/SchemaDerivation.scala @@ -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] }) ) }