Skip to content

Commit

Permalink
Automatic propagation of schema name as schema title (#3593)
Browse files Browse the repository at this point in the history
  • Loading branch information
ghik authored Mar 13, 2024
1 parent 5ef87a9 commit 17af136
Show file tree
Hide file tree
Showing 98 changed files with 209 additions and 20 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ class SchemasForEndpoints(
val keysToIds: Map[SchemaKey, SchemaId] = calculateUniqueIds(keyedCombinedSchemas.map(_._1), (key: SchemaKey) => schemaName(key.name))

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

val keysToSchemas: ListMap[SchemaKey, ASchema] =
keyedCombinedSchemas.map(td => (td._1, tschemaToASchema(td._2, allowReference = false))).toListMap
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package sttp.tapir.docs.apispec.schema

import sttp.apispec.{Schema => ASchema, _}
import sttp.tapir.Schema.Title
import sttp.tapir.Schema.{SName, Title}
import sttp.tapir.Validator.EncodeToRaw
import sttp.tapir.docs.apispec.DocsExtensionAttribute.RichSchema
import sttp.tapir.docs.apispec.schema.TSchemaToASchema.{tDefaultToADefault, tExampleToAExample}
Expand All @@ -10,7 +10,11 @@ import sttp.tapir.internal._
import sttp.tapir.{Codec, Validator, Schema => TSchema, SchemaType => TSchemaType}

/** Converts a tapir schema to an OpenAPI/AsyncAPI schema, using `toSchemaReference` to resolve references. */
private[docs] class TSchemaToASchema(toSchemaReference: ToSchemaReference, markOptionsAsNullable: Boolean) {
private[docs] class TSchemaToASchema(
fallbackSchemaTitle: SName => String,
toSchemaReference: ToSchemaReference,
markOptionsAsNullable: Boolean
) {

def apply[T](codec: Codec[T, _, _]): ASchema = apply(codec.schema, allowReference = true)

Expand Down Expand Up @@ -93,8 +97,13 @@ private[docs] class TSchemaToASchema(toSchemaReference: ToSchemaReference, markO
.toListMap
}

private def addTitle(oschema: ASchema, tschema: TSchema[_]): ASchema =
oschema.copy(title = tschema.attributes.get(Title.Attribute).map(_.value))
private def addTitle(oschema: ASchema, tschema: TSchema[_]): ASchema = {
val fromAttr = tschema.attributes.get(Title.Attribute).map(_.value)
// The primary motivation for using schema name as fallback title is to improve Swagger UX with
// `oneOf` schemas in OpenAPI 3.1. See https://github.com/softwaremill/tapir/issues/3447 for details.
def fallback = tschema.name.map(fallbackSchemaTitle)
oschema.copy(title = fromAttr orElse fallback)
}

private def addMetadata(oschema: ASchema, tschema: TSchema[_]): ASchema = {
oschema.copy(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ object TapirSchemaToJsonSchema {
def apply(
schema: TSchema[_],
markOptionsAsNullable: Boolean,
addTitleToDefs: Boolean = true,
metaSchema: MetaSchema = MetaSchemaDraft04,
schemaName: TSchema.SName => String = defaultSchemaName
): ASchema = {
Expand All @@ -19,22 +18,25 @@ object TapirSchemaToJsonSchema {

val keysToIds = calculateUniqueIds(keyedSchemas.map(_._1), (key: SchemaKey) => schemaName(key.name))
val toSchemaReference = new ToSchemaReference(keysToIds, keyedSchemas.toMap, refRoot = "#/$defs/")
val tschemaToASchema = new TSchemaToASchema(toSchemaReference, markOptionsAsNullable)
val tschemaToASchema = new TSchemaToASchema(schemaName, toSchemaReference, markOptionsAsNullable)
val keysToSchemas = keyedSchemas.map(td => (td._1, tschemaToASchema(td._2, allowReference = false))).toListMap
val schemaIds = keysToSchemas.map { case (k, v) => k -> ((keysToIds(k), v)) }

val nestedKeyedSchemas = schemaIds.values
val defsList = schemaIds.values.toListMap
val rootApiSpecSchemaOrRef: ASchema = tschemaToASchema(schema, allowReference = false)

val defsList: ListMap[SchemaId, ASchema] =
nestedKeyedSchemas.collect {
case (k, nestedSchema: ASchema) if nestedSchema.$ref.isEmpty =>
(k, nestedSchema.copy(title = nestedSchema.title.orElse(if (addTitleToDefs) Some(k) else None)))
}.toListMap

rootApiSpecSchemaOrRef.copy(
`$schema` = Some(metaSchema.schemaId),
`$defs` = if (defsList.nonEmpty) Some(defsList) else None
)
}

// binary compatibility shim
private[docs] def apply(
schema: TSchema[_],
markOptionsAsNullable: Boolean,
addTitleToDefs: Boolean,
metaSchema: MetaSchema,
schemaName: TSchema.SName => String
): ASchema = apply(schema, markOptionsAsNullable, metaSchema, schemaName)
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ class JsonSchemasTest extends AnyFlatSpec with Matchers with OptionValues with E
val result: ASchema = TapirSchemaToJsonSchema(tSchema, markOptionsAsNullable = true)

// then
result.asJson.deepDropNullValues shouldBe json"""{"$$schema":"http://json-schema.org/draft-04/schema#","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"}}}}}}"""
result.asJson.deepDropNullValues shouldBe json"""{"$$schema":"http://json-schema.org/draft-04/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"}}}}}}"""

}

Expand Down Expand Up @@ -54,7 +54,7 @@ class JsonSchemasTest extends AnyFlatSpec with Matchers with OptionValues with E
val result = TapirSchemaToJsonSchema(tSchema, markOptionsAsNullable = true)

// then
result.asJson.deepDropNullValues shouldBe json"""{"$$schema":"http://json-schema.org/draft-04/schema#","required":["innerChildField","childDetails"],"type":"object","properties":{"innerChildField":{"$$ref":"#/$$defs/Child"},"childDetails":{"$$ref":"#/$$defs/Child1"}},"$$defs":{"Child":{"title":"Child","required":["childName"],"type":"object","properties":{"childName":{"type":"string"}}},"Child1":{"title":"Child1","required":["age"],"type":"object","properties":{"age":{"type":"integer","format":"int32"},"height":{"type":["integer", "null"],"format":"int32"}}}}}"""
result.asJson.deepDropNullValues shouldBe json"""{"$$schema":"http://json-schema.org/draft-04/schema#","title":"Parent","required":["innerChildField","childDetails"],"type":"object","properties":{"innerChildField":{"$$ref":"#/$$defs/Child"},"childDetails":{"$$ref":"#/$$defs/Child1"}},"$$defs":{"Child":{"title":"Child","required":["childName"],"type":"object","properties":{"childName":{"type":"string"}}},"Child1":{"title":"Child","required":["age"],"type":"object","properties":{"age":{"type":"integer","format":"int32"},"height":{"type":["integer", "null"],"format":"int32"}}}}}"""
}

it should "handle options as not nullable" in {
Expand All @@ -67,7 +67,7 @@ class JsonSchemasTest extends AnyFlatSpec with Matchers with OptionValues with E
val result = TapirSchemaToJsonSchema(tSchema, markOptionsAsNullable = false)

// then
result.asJson.deepDropNullValues shouldBe json"""{"$$schema":"http://json-schema.org/draft-04/schema#","required":["innerChildField"],"type":"object","properties":{"innerChildField":{"$$ref":"#/$$defs/Child"}},"$$defs":{"Child":{"title":"Child","type":"object","properties":{"childName":{"type":"string"}}}}}"""
result.asJson.deepDropNullValues shouldBe json"""{"$$schema":"http://json-schema.org/draft-04/schema#","title":"Parent","required":["innerChildField"],"type":"object","properties":{"innerChildField":{"$$ref":"#/$$defs/Child"}},"$$defs":{"Child":{"title":"Child","type":"object","properties":{"childName":{"type":"string"}}}}}"""

}

Expand All @@ -81,7 +81,7 @@ class JsonSchemasTest extends AnyFlatSpec with Matchers with OptionValues with E
val result = TapirSchemaToJsonSchema(tSchema, markOptionsAsNullable = true)

// then
result.asJson.deepDropNullValues shouldBe json"""{"$$schema":"http://json-schema.org/draft-04/schema#","required":["innerChildField"],"type":"object","properties":{"innerChildField":{"$$ref":"#/$$defs/Child"}},"$$defs":{"Child":{"title":"Child","type":"object","properties":{"childName":{"type":["string","null"]}}}}}"""
result.asJson.deepDropNullValues shouldBe json"""{"$$schema":"http://json-schema.org/draft-04/schema#","title":"Parent","required":["innerChildField"],"type":"object","properties":{"innerChildField":{"$$ref":"#/$$defs/Child"}},"$$defs":{"Child":{"title":"Child","type":"object","properties":{"childName":{"type":["string","null"]}}}}}"""
}

it should "use title from annotation or ref name" in {
Expand Down Expand Up @@ -113,9 +113,9 @@ class JsonSchemasTest extends AnyFlatSpec with Matchers with OptionValues with E
val tSchema = implicitly[Schema[Parent]]

// when
val result = TapirSchemaToJsonSchema(tSchema, markOptionsAsNullable = true, addTitleToDefs = false)
val result = TapirSchemaToJsonSchema(tSchema, markOptionsAsNullable = true)

// then
result.asJson.deepDropNullValues shouldBe json"""{"$$schema":"http://json-schema.org/draft-04/schema#","required":["innerChildField"],"type":"object","properties":{"innerChildField":{"$$ref":"#/$$defs/Child"}},"$$defs":{"Child":{"title":"MyChild","type":"object","properties":{"childName":{"type":["string","null"]}}}}}"""
result.asJson.deepDropNullValues shouldBe json"""{"$$schema":"http://json-schema.org/draft-04/schema#","title":"Parent","required":["innerChildField"],"type":"object","properties":{"innerChildField":{"$$ref":"#/$$defs/Child"}},"$$defs":{"Child":{"title":"MyChild","type":"object","properties":{"childName":{"type":["string","null"]}}}}}"""
}
}
1 change: 1 addition & 0 deletions docs/asyncapi-docs/src/test/resources/expected_binding.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ channels:
components:
schemas:
Fruit:
title: Fruit
required:
- f
type: object
Expand Down
2 changes: 2 additions & 0 deletions docs/asyncapi-docs/src/test/resources/expected_extensions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ channels:
components:
schemas:
FruitAmount:
title: FruitAmount
required:
- fruit
- amount
Expand All @@ -43,6 +44,7 @@ components:
type: integer
format: int32
Fruit:
title: Fruit
required:
- f
type: object
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ channels:
components:
schemas:
sttp.tapir.tests.data.Fruit:
title: sttp.tapir.tests.data.Fruit
required:
- f
type: object
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ channels:
components:
schemas:
Fruit:
title: Fruit
required:
- f
type: object
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ channels:
components:
schemas:
Fruit:
title: Fruit
required:
- f
type: object
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ channels:
components:
schemas:
Fruit:
title: Fruit
required:
- f
type: object
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ channels:
components:
schemas:
Fruit:
title: Fruit
required:
- f
type: object
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,15 @@ channels:
components:
schemas:
Fruit:
title: Fruit
required:
- f
type: object
properties:
f:
type: string
FruitAmount:
title: FruitAmount
required:
- fruit
- amount
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,20 @@ paths:
components:
schemas:
Entity:
title: Entity
oneOf:
- $ref: '#/components/schemas/Organization'
- $ref: '#/components/schemas/Person'
Organization:
title: Organization
required:
- name
type: object
properties:
name:
type: string
Person:
title: Person
required:
- name
- age
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ paths:
components:
schemas:
Entity:
title: Entity
oneOf:
- $ref: '#/components/schemas/Organization'
- $ref: '#/components/schemas/Person'
Expand All @@ -25,13 +26,15 @@ components:
john: '#/components/schemas/Person'
sml: '#/components/schemas/Organization'
Organization:
title: Organization
required:
- name
type: object
properties:
name:
type: string
Person:
title: Person
required:
- name
- age
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ paths:
components:
schemas:
Entity:
title: Entity
oneOf:
- $ref: '#/components/schemas/Organization'
- $ref: '#/components/schemas/Person'
Expand All @@ -25,20 +26,23 @@ components:
john: '#/components/schemas/Person'
sml: '#/components/schemas/Organization'
NestedEntity:
title: NestedEntity
required:
- entity
type: object
properties:
entity:
$ref: '#/components/schemas/Entity'
Organization:
title: Organization
required:
- name
type: object
properties:
name:
type: string
Person:
title: Person
required:
- name
- age
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@ paths:
components:
schemas:
Shape:
title: Shape
oneOf:
- $ref: '#/components/schemas/Square'
discriminator:
propertyName: shapeType
mapping:
Square: '#/components/schemas/Square'
Square:
title: Square
required:
- color
- shapeType
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ paths:
components:
schemas:
Entity:
title: Entity
oneOf:
- $ref: '#/components/schemas/Organization'
- $ref: '#/components/schemas/Person1'
Expand All @@ -63,6 +64,7 @@ components:
organization: '#/components/schemas/Organization'
person: '#/components/schemas/Person1'
Organization:
title: Organization
required:
- name
- kind
Expand All @@ -73,6 +75,7 @@ components:
kind:
type: string
Person:
title: Person
required:
- name
- age
Expand All @@ -84,6 +87,7 @@ components:
type: integer
format: int32
Person1:
title: Person
required:
- name
- age
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,24 +16,28 @@ paths:
components:
schemas:
Entity:
title: Entity
oneOf:
- $ref: '#/components/schemas/Organization'
- $ref: '#/components/schemas/Person'
NestedEntity:
title: NestedEntity
required:
- entity
type: object
properties:
entity:
$ref: '#/components/schemas/Entity'
Organization:
title: Organization
required:
- name
type: object
properties:
name:
type: string
Person:
title: Person
required:
- name
- age
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ paths:
components:
schemas:
Entity:
title: Entity
oneOf:
- required:
- Organization
Expand All @@ -44,13 +45,15 @@ components:
Person:
$ref: '#/components/schemas/Person'
Organization:
title: Organization
required:
- name
type: object
properties:
name:
type: string
Person:
title: Person
required:
- name
- age
Expand Down
Loading

0 comments on commit 17af136

Please sign in to comment.