Skip to content

Commit a253330

Browse files
felix-hedenstromfelix
andauthored
Fail on duplicate schema name (#5068)
I encountered questions by integration partners when we had duplicate names for models which automatically was supplied suffix numbers. We don't want internal scala refactors resulting in changes for client and I would have liked to have caught this earlier so that I could have assigned the models more appropriate names. --------- Co-authored-by: felix <[email protected]>
1 parent 0bf1fa2 commit a253330

File tree

10 files changed

+69
-10
lines changed

10 files changed

+69
-10
lines changed

doc/docs/openapi.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,10 @@ Options can be customised by providing an instance of `OpenAPIDocsOptions` to th
216216
* `failOnDuplicateOperationId`: if set to `true`, the interpreter will throw an exception if it encounters two endpoints
217217
with the same operation id. An OpenAPI document with duplicate operation ids is not valid. Code generators can
218218
silently drop duplicates. This is also verified by the [endpoint verifier](../testing.md).
219+
* `failOnDuplicateSchemaName`: if set to `true`, the interpreter will throw an exception if it encounters two schemas
220+
which (without automatic deduplication by adding a numeric suffix) would be identical. Having automatically resolved
221+
de-duplications might result in different names depending on the order of endpoints. This might result in false
222+
positive changes in the OpenApi document.
219223

220224
## Inlined and referenced schemas
221225

docs/apispec-docs/src/main/scala/sttp/tapir/docs/apispec/schema/SchemasForEndpoints.scala

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ class SchemasForEndpoints(
1111
es: Iterable[AnyEndpoint],
1212
schemaName: SName => String,
1313
markOptionsAsNullable: Boolean,
14+
failOnDuplicateSchemaName: Boolean,
1415
additionalOutputs: List[EndpointOutput[_]]
1516
) {
1617

@@ -24,7 +25,8 @@ class SchemasForEndpoints(
2425
forInput(e.securityInput) ++ forInput(e.input) ++ forOutput(e.errorOutput) ++ forOutput(e.output)
2526
) ++ additionalOutputs.flatMap(forOutput(_))
2627
)
27-
val keysToIds: Map[SchemaKey, SchemaId] = calculateUniqueIds(keyedCombinedSchemas.map(_._1), (key: SchemaKey) => schemaName(key.name))
28+
val keysToIds: Map[SchemaKey, SchemaId] =
29+
calculateUniqueIds(keyedCombinedSchemas.map(_._1), (key: SchemaKey) => schemaName(key.name), failOnDuplicateSchemaName)
2830

2931
val toSchemaReference = new ToSchemaReference(keysToIds, keyedCombinedSchemas.toMap)
3032
val tschemaToASchema = new TSchemaToASchema(schemaName, toSchemaReference, markOptionsAsNullable)

docs/apispec-docs/src/main/scala/sttp/tapir/docs/apispec/schema/TapirSchemaToJsonSchema.scala

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ object TapirSchemaToJsonSchema {
1414
def apply(
1515
schema: TSchema[_],
1616
markOptionsAsNullable: Boolean,
17+
failOnDuplicateSchemaName: Boolean = false,
1718
metaSchema: MetaSchema = MetaSchemaDraft202012,
1819
schemaName: TSchema.SName => String = defaultSchemaName
1920
): ASchema = {
@@ -32,7 +33,7 @@ object TapirSchemaToJsonSchema {
3233

3334
val keyedSchemas = ToKeyedSchemas.uniqueCombined(asKeyedSchemas)
3435

35-
val keysToIds = calculateUniqueIds(keyedSchemas.map(_._1), (key: SchemaKey) => schemaName(key.name))
36+
val keysToIds = calculateUniqueIds(keyedSchemas.map(_._1), (key: SchemaKey) => schemaName(key.name), failOnDuplicateSchemaName)
3637
val toSchemaReference = new ToSchemaReference(keysToIds, keyedSchemas.toMap, refRoot = "#/$defs/")
3738
val tschemaToASchema = new TSchemaToASchema(schemaName, toSchemaReference, markOptionsAsNullable)
3839
val keysToSchemas = keyedSchemas.map(td => (td._1, tschemaToASchema(td._2, allowReference = false))).toListMap

docs/apispec-docs/src/main/scala/sttp/tapir/docs/apispec/schema/schema.scala

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@ package object schema {
2020
private[docs] type KeyedSchema = (SchemaKey, TSchema[_])
2121
private[docs] type SchemaId = String
2222

23-
private[docs] def calculateUniqueIds[T](ts: Iterable[T], toIdBase: T => String): Map[T, String] = {
23+
private[docs] def calculateUniqueIds[T](ts: Iterable[T], toIdBase: T => String, failOnDuplicateSchemaName: Boolean): Map[T, String] = {
2424
case class Assigment(idToT: Map[String, T], tToId: Map[T, String])
25-
ts
25+
val result = ts
2626
.foldLeft(Assigment(Map.empty, Map.empty)) { case (Assigment(idToT, tToId), t) =>
2727
val id = uniqueString(toIdBase(t), n => !idToT.contains(n) || idToT.get(n).contains(t))
2828

@@ -31,7 +31,21 @@ package object schema {
3131
tToId + (t -> id)
3232
)
3333
}
34-
.tToId
34+
35+
if (failOnDuplicateSchemaName) {
36+
val conflicts: Map[T, String] = result.tToId.collect { case (t, id) if toIdBase(t) != id => t -> id }
37+
38+
if (conflicts.nonEmpty) {
39+
// Extract unique base names that had conflicts
40+
val baseNames = conflicts.map { case (t, _) => toIdBase(t) }.toSet.toList.sorted
41+
throw new IllegalStateException(
42+
s"Duplicate schema names found: ${baseNames.mkString(", ")}. " +
43+
"Consider using unique class names or customize the schemaName function."
44+
)
45+
}
46+
}
47+
48+
result.tToId
3549
}
3650

3751
private[docs] def propagateMetadataForOption[T, E](schema: TSchema[T], opt: TSchemaType.SOption[T, E]): TSchemaType.SOption[T, E] = {

docs/apispec-docs/src/test/scala/sttp/tapir/docs/apispec/schema/TapirSchemaToJsonSchemaTest.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ class TapirSchemaToJsonSchemaTest extends AnyFlatSpec with Matchers with OptionV
2323
val tSchema = implicitly[Schema[Parent]]
2424

2525
// when
26-
val result: ASchema = TapirSchemaToJsonSchema(tSchema, markOptionsAsNullable = true)
26+
val result: ASchema = TapirSchemaToJsonSchema(tSchema, markOptionsAsNullable = true, failOnDuplicateSchemaName = false)
2727

2828
// then
2929
result.asJson.deepDropNullValues shouldBe json"""{"$$schema":"https://json-schema.org/draft/2020-12/schema","title":"Parent","required":["innerChildField"],"type":"object","properties":{"innerChildField":{"$$ref":"#/$$defs/Child"}},"$$defs":{"Child":{"title":"Child","required":["childId"],"type":"object","properties":{"childId":{"type":"string"},"childNames":{"type":"array","items":{"type":"string"}}}}}}"""

docs/asyncapi-docs/src/main/scala/sttp/tapir/docs/asyncapi/EndpointToAsyncAPIDocs.scala

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,13 @@ private[asyncapi] object EndpointToAsyncAPIDocs {
1717
val wsEndpointsWithWrapper = es.map(e => (e, findWebSocket(e))).collect { case (e, Some(ws)) => (e, ws) }
1818
val wsEndpoints = wsEndpointsWithWrapper.map(_._1).map(nameAllPathCapturesInEndpoint)
1919
val (keyToSchema, schemas) =
20-
new SchemasForEndpoints(wsEndpoints, options.schemaName, markOptionsAsNullable = false, additionalOutputs = Nil)
20+
new SchemasForEndpoints(
21+
wsEndpoints,
22+
options.schemaName,
23+
markOptionsAsNullable = false,
24+
failOnDuplicateSchemaName = false,
25+
additionalOutputs = Nil
26+
)
2127
.apply()
2228
val (codecToMessageKey, keyToMessage) = new MessagesForEndpoints(schemas, options.schemaName)(
2329
wsEndpointsWithWrapper.map(_._2)

docs/asyncapi-docs/src/main/scala/sttp/tapir/docs/asyncapi/MessagesForEndpoints.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ private[asyncapi] class MessagesForEndpoints(tschemaToASchema: TSchemaToASchema,
2020
val codecs: Iterable[CodecWithInfo[_]] = wss.flatMap(ws => codecsFor(ws.wrapped))
2121
val codecToData: ListMap[CodecWithInfo[_], CodecData] = codecs.toList.map(ci => ci -> toData(ci.codec)).toListMap
2222

23-
val dataToKey = calculateUniqueIds(codecToData.values.toSet, dataToName)
23+
val dataToKey = calculateUniqueIds(codecToData.values.toSet, dataToName, failOnDuplicateSchemaName = false)
2424
val codecToKey = codecToData.map { case (ci, data) => ci.codec -> dataToKey(data) }.toMap[Codec[_, _, _ <: CodecFormat], String]
2525
val keyToMessage = codecToData.map { case (ci, data) => dataToKey(data) -> message(ci) }
2626

docs/openapi-docs/src/main/scala/sttp/tapir/docs/openapi/EndpointToOpenAPIDocs.scala

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ private[openapi] object EndpointToOpenAPIDocs {
1818
val es2 = es.filter(e => findWebSocket(e).isEmpty).map(nameAllPathCapturesInEndpoint)
1919
val additionalOutputs = es2.flatMap(e => options.defaultDecodeFailureOutput(e.input)).toSet.toList
2020
val (idToSchema, tschemaToASchema) =
21-
new SchemasForEndpoints(es2, options.schemaName, options.markOptionsAsNullable, additionalOutputs).apply()
21+
new SchemasForEndpoints(es2, options.schemaName, options.markOptionsAsNullable, options.failOnDuplicateSchemaName, additionalOutputs)
22+
.apply()
2223
val securitySchemes = SecuritySchemesForEndpoints(es2, apiKeyAuthTypeName = "apiKey")
2324
val pathCreator = new EndpointToOpenAPIPaths(tschemaToASchema, securitySchemes, options)
2425
val componentsCreator = new EndpointToOpenAPIComponents(idToSchema, securitySchemes)

docs/openapi-docs/src/main/scala/sttp/tapir/docs/openapi/OpenAPIDocsOptions.scala

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ case class OpenAPIDocsOptions(
1111
schemaName: SName => String = defaultSchemaName,
1212
defaultDecodeFailureOutput: EndpointInput[_] => Option[EndpointOutput[_]] = OpenAPIDocsOptions.defaultDecodeFailureOutput,
1313
markOptionsAsNullable: Boolean = false,
14-
failOnDuplicateOperationId: Boolean = false
14+
failOnDuplicateOperationId: Boolean = false,
15+
failOnDuplicateSchemaName: Boolean = false
1516
)
1617

1718
object OpenAPIDocsOptions {

docs/openapi-docs/src/test/scalajvm/sttp/tapir/docs/openapi/EndpointToOpenAPIDocsTest.scala

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ import sttp.tapir.tests.Files._
1212
import sttp.tapir.tests.Mapping._
1313
import sttp.tapir.tests.Multipart._
1414
import sttp.tapir.tests.OneOf._
15+
import io.circe.generic.auto._
16+
import sttp.tapir.docs.openapi.dtos.a.{Pet => APet}
17+
import sttp.tapir.docs.openapi.dtos.b.{Pet => BPet}
18+
import sttp.tapir.generic.auto._
19+
import sttp.tapir.json.circe._
1520

1621
class EndpointToOpenAPIDocsTest extends AnyFunSuite with Matchers {
1722

@@ -134,4 +139,29 @@ class EndpointToOpenAPIDocsTest extends AnyFunSuite with Matchers {
134139

135140
OpenAPIDocsInterpreter(options).toOpenAPI(es, Info("title", "19.2-beta-RC1"))
136141
}
142+
143+
test("should fail when OpenAPIDocsOptions.failOnDuplicateSchemaName is true and there are duplicate schema names") {
144+
val e: sttp.tapir.Endpoint[Unit, APet, Unit, BPet, Any] = endpoint
145+
.in(jsonBody[APet])
146+
.out(jsonBody[BPet])
147+
148+
val options = OpenAPIDocsOptions.default.copy(failOnDuplicateSchemaName = true)
149+
150+
val thrown = intercept[IllegalStateException] {
151+
OpenAPIDocsInterpreter(options).toOpenAPI(e, Info("Entities", "1.0"))
152+
}
153+
154+
thrown.getMessage should include("Duplicate schema names found")
155+
thrown.getMessage should include("Pet")
156+
}
157+
158+
test("should pass when OpenAPIDocsOptions.failOnDuplicateSchemaName is false and there are duplicate schema names") {
159+
val e: sttp.tapir.Endpoint[Unit, APet, Unit, BPet, Any] = endpoint
160+
.in(jsonBody[APet])
161+
.out(jsonBody[BPet])
162+
163+
val options = OpenAPIDocsOptions.default.copy(failOnDuplicateSchemaName = false)
164+
165+
OpenAPIDocsInterpreter(options).toOpenAPI(e, Info("Entities", "1.0"))
166+
}
137167
}

0 commit comments

Comments
 (0)