Skip to content

Commit

Permalink
ProductAsArray attribute added, alternative rendering of SProduct (#3941
Browse files Browse the repository at this point in the history
)

Signed-off-by: Marcin Kielar <[email protected]>
Co-authored-by: adamw <[email protected]>
  • Loading branch information
zorba128 and adamw authored Jul 25, 2024
1 parent d50e544 commit 57bb396
Show file tree
Hide file tree
Showing 5 changed files with 46 additions and 10 deletions.
11 changes: 11 additions & 0 deletions core/src/main/scala/sttp/tapir/Schema.scala
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,17 @@ object Schema extends LowPrioritySchema with SchemaCompanionMacros {
val Attribute: AttributeKey[UniqueItems] = new AttributeKey[UniqueItems]("sttp.tapir.Schema.UniqueItems")
}

/** Hints that a [[SchemaType.SProduct]] should be rendered in the schema as an `array` (#3941).
*
* Used to model tuples, which are products with no meaningful property names - attributes are identified by their position. When
* converting to JSON schema, adding this attribute on a schema for a `SProduct` renders `array` schema, with type constraints for each
* index.
*/
case class ProductAsArray(productAsArray: Boolean)
object ProductAsArray {
val Attribute: AttributeKey[ProductAsArray] = new AttributeKey[ProductAsArray]("sttp.tapir.Schema.ProductAsArray")
}

/** @param typeParameterShortNames
* full name of type parameters, name is legacy and kept only for backward compatibility
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,7 @@ sealed trait MetaSchema {
case object MetaSchemaDraft04 extends MetaSchema {
override lazy val schemaId: String = "http://json-schema.org/draft-04/schema#"
}

case object MetaSchemaDraft202012 extends MetaSchema {
override lazy val schemaId: String = "http://json-schema.org/draft/2020-12/schema#"
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ private[docs] class TSchemaToASchema(
case TSchemaType.SNumber() => ASchema(SchemaType.Number)
case TSchemaType.SBoolean() => ASchema(SchemaType.Boolean)
case TSchemaType.SString() => ASchema(SchemaType.String)
case TSchemaType.SProduct(fields) if schema.attribute(TSchema.ProductAsArray.Attribute).map(_.productAsArray).getOrElse(false) =>
ASchema(SchemaType.Array).copy(
prefixItems = Some(fields.map(f => apply(f.schema, allowReference = true)))
)
case p @ TSchemaType.SProduct(fields) =>
ASchema(SchemaType.Object).copy(
required = p.required.map(_.encodedName),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,16 @@ import sttp.tapir.internal.IterableToListMap
import sttp.tapir.{Schema => TSchema}
import scala.collection.immutable.ListMap

/** Renders json schema from tapir schema.
*
* Note [[MetaSchemaDraft04]] is accepted for compatibility, but the [[sttp.apispec.Schema]] produced always follows
* [[MetaSchemaDraft202012]].
*/
object TapirSchemaToJsonSchema {
def apply(
schema: TSchema[_],
markOptionsAsNullable: Boolean,
metaSchema: MetaSchema = MetaSchemaDraft04,
metaSchema: MetaSchema = MetaSchemaDraft202012,
schemaName: TSchema.SName => String = defaultSchemaName
): ASchema = {

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

}

Expand All @@ -38,7 +38,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#","type":"array","items":{"type":"integer","format":"int32"}}"""
result.asJson.deepDropNullValues shouldBe json"""{"$$schema":"http://json-schema.org/draft/2020-12/schema#","type":"array","items":{"type":"integer","format":"int32"}}"""
}

it should "handle repeated type names" in {
Expand All @@ -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#","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"}}}}}"""
result.asJson.deepDropNullValues shouldBe json"""{"$$schema":"http://json-schema.org/draft/2020-12/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#","title":"Parent","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/2020-12/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#","title":"Parent","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/2020-12/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 All @@ -100,7 +100,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#","title":"MyOwnTitle1","required":["inner"],"type":"object","properties":{"inner":{"$$ref":"#/$$defs/Parent"}},"$$defs":{"Parent":{"title":"Parent","required":["innerChildField"],"type":"object","properties":{"innerChildField":{"$$ref":"#/$$defs/Child"}}},"Child":{"title":"MyOwnTitle3","type":"object","properties":{"childName":{"type":["string","null"]}}}}}"""
result.asJson.deepDropNullValues shouldBe json"""{"$$schema":"http://json-schema.org/draft/2020-12/schema#","title":"MyOwnTitle1","required":["inner"],"type":"object","properties":{"inner":{"$$ref":"#/$$defs/Parent"}},"$$defs":{"Parent":{"title":"Parent","required":["innerChildField"],"type":"object","properties":{"innerChildField":{"$$ref":"#/$$defs/Child"}}},"Child":{"title":"MyOwnTitle3","type":"object","properties":{"childName":{"type":["string","null"]}}}}}"""
}

it should "NOT use generate default titles if disabled" in {
Expand All @@ -116,7 +116,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#","title":"Parent","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/2020-12/schema#","title":"Parent","required":["innerChildField"],"type":"object","properties":{"innerChildField":{"$$ref":"#/$$defs/Child"}},"$$defs":{"Child":{"title":"MyChild","type":"object","properties":{"childName":{"type":["string","null"]}}}}}"""
}

it should "Generate correct names for Eithers with parameterized types" in {
Expand All @@ -137,10 +137,10 @@ class JsonSchemasTest extends AnyFlatSpec with Matchers with OptionValues with E
"Either_SomeValueInt_Node_String"
)
TapirSchemaToJsonSchema(implicitly[Schema[Either[Node[Boolean], SomeValueInt]]], true).title shouldBe Some(
"Either_Node_Boolean_SomeValueInt"
"Either_Node_Boolean_SomeValueInt"
)
}

it should "Generate correct names for Maps with parameterized types" in {
type Tree[A] = Either[A, Node[A]]
final case class Node[A](values: List[A])
Expand All @@ -149,4 +149,16 @@ class JsonSchemasTest extends AnyFlatSpec with Matchers with OptionValues with E
val schema2: Schema[Map[Tree[Int], String]] = Schema.schemaForMap(_.toString)
TapirSchemaToJsonSchema(schema2, true).title shouldBe Some("Map_Either_Int_Node_Int_String")
}

it should "Generate array for products marked with ProductAsArray attribute" in {
val tSchema: Schema[(Int, String)] = implicitly[Schema[(Int, String)]]
.attribute(Schema.ProductAsArray.Attribute, Schema.ProductAsArray(true))

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

// then
result.asJson.deepDropNullValues shouldBe
json"""{"$$schema" : "http://json-schema.org/draft/2020-12/schema#", "title" : "Tuple2_Int_String", "type" : "array", "prefixItems" : [{"type" : "integer", "format" : "int32"}, {"type" : "string"}]}"""
}
}

0 comments on commit 57bb396

Please sign in to comment.