Skip to content
Merged
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
11 changes: 5 additions & 6 deletions apispec-model/src/main/scala/sttp/apispec/model.scala
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,12 @@ import scala.collection.immutable.ListMap
sealed trait ExampleValue

/** @param value
* An `Int`, `Long`, `Short`, `Float`, `Double`, `BigDecimal`, `BigInt`, `Boolean`, `null`, `String` or
* `io.circe.Json`. Values in other formats, such as XML, are given as a `String`. Values of other types are written
* as strings, using `toString`.
* The value as it appears in the document: a `Boolean`, `null`, number (`Int`, `Long`, `Short`, `Float`, `Double`,
* `BigDecimal` or `BigInt`) or `String`. Other values are written as strings, using `toString`.
*
* A `String` which is valid JSON is written as that JSON value (e.g. `"200"` as a number). In the `enum`, `const`,
* `default` and `examples` of a string [[Schema]], this only applies to JSON strings and `null` (e.g. `"\"a\""` or
* `"null"`).
* A `String` can also hold the value's encoded text, e.g. a codec's output (`"200"`, `"\"a\""` or `"{\"a\":1}"`). Text
* which is valid JSON is written as that JSON value; in a string [[Schema]], only JSON strings and `null` are. Objects
* and arrays are given as JSON text (e.g. `"{\"a\":1}"`), or an array as an [[ExampleMultipleValue]].
*/
case class ExampleSingleValue(value: Any) extends ExampleValue

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -354,11 +354,18 @@ class SchemaComparator(
case _ => writerTpe == readerTpe
}

// A decoded value such as BigDecimal("1.1") isn't equal to 1.1f, as no Float holds 1.1 exactly.
private def sameEnumValue(v1: ExampleValue, v2: ExampleValue): Boolean = (v1, v2) match {
case (ExampleSingleValue(f: Float), ExampleSingleValue(d: BigDecimal)) => d.toFloat == f
case (ExampleSingleValue(d: BigDecimal), ExampleSingleValue(f: Float)) => d.toFloat == f
case _ => v1 == v2
private def sameExampleValue(v1: ExampleValue, v2: ExampleValue): Boolean = (v1, v2) match {
case (ExampleSingleValue(a), ExampleSingleValue(b)) => sameValue(a, b)
case (ExampleMultipleValue(as), ExampleMultipleValue(bs)) => as.corresponds(bs)(sameValue)
case _ => false
}

// A decoded value such as BigDecimal("1.1") isn't equal to 1.1f, as no Float holds 1.1 exactly. Other numbers, e.g.
// BigDecimal(1) and 1, are equal using ==.
private def sameValue(v1: Any, v2: Any): Boolean = (v1, v2) match {
case (f: Float, d: BigDecimal) => d.toFloat == f
case (d: BigDecimal, f: Float) => d.toFloat == f
case _ => v1 == v2
}

private def checkEnumAndConst(writerSchema: Schema, readerSchema: Schema): Option[EnumMismatch] = {
Expand All @@ -369,7 +376,7 @@ class SchemaComparator(
Some(EnumMismatch(None, readerValues))
case (Some(writerValues), Some(readerValues)) =>
val incompatibleWriterValues =
writerValues.filter(wv => !readerValues.exists(sameEnumValue(wv, _)))
writerValues.filter(wv => !readerValues.exists(sameExampleValue(wv, _)))
if (incompatibleWriterValues.isEmpty) None
else Some(EnumMismatch(Some(incompatibleWriterValues), readerValues))
case _ =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,10 @@ abstract class SchemaComparatorTest(referencePrefix: String) extends AnyFunSuite
val decimalEnum = numberSchema.copy(`enum` = Some(enums(BigDecimal("1.1"))))
assert(compare(floatEnum, decimalEnum) == Nil)
assert(compare(decimalEnum, floatEnum) == Nil)

val floatListEnum = numberSchema.copy(`enum` = Some(List(ExampleMultipleValue(List(1.1f)))))
val decimalListEnum = numberSchema.copy(`enum` = Some(List(ExampleMultipleValue(List(BigDecimal("1.1"))))))
assert(compare(floatListEnum, decimalListEnum) == Nil)
}

test("incompatible enum & const") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,16 @@ trait JsonSchemaCirceDecoders {
implicit val discriminatorDecoder: Decoder[Discriminator] = deriveDecoder[Discriminator]

/** Decodes scalars to the Scala values that schemas built in code use, so that decoded and in-code values compare
* equal. Arrays and objects stay as [[Json]].
* equal. Arrays and objects are decoded to their JSON text.
*/
private def decodeExampleValue(json: Json): Any =
json.fold(
jsonNull = null,
jsonBoolean = b => b,
jsonNumber = n => n.toInt.orElse(n.toLong).orElse(n.toBigDecimal).getOrElse(n.toDouble),
jsonString = s => s,
jsonArray = _ => json,
jsonObject = _ => json
jsonArray = _ => json.noSpaces,
jsonObject = _ => json.noSpaces
)

implicit val exampleSingleValueDecoder: Decoder[ExampleSingleValue] =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,6 @@ trait JsonSchemaCirceEncoders {
case ExampleSingleValue(value: BigDecimal) => Json.fromBigDecimal(value)
case ExampleSingleValue(value: BigInt) => Json.fromBigInt(value)
case ExampleSingleValue(null) => Json.Null
case ExampleSingleValue(value: Json) => value
case ExampleSingleValue(value) => Json.fromString(value.toString)
}

Expand All @@ -157,8 +156,10 @@ trait JsonSchemaCirceEncoders {
case e: ExampleSingleValue => encoderExampleSingleValue.apply(e)
}

// Strings are parsed as JSON (so "200" becomes 200), except in string schemas: there, only JSON-encoded strings and
// nulls (e.g. "\"a\"" or "null", as tapir passes for JSON codecs) are unwrapped.
// The same as encoderExampleValue, except for strings in string schemas: there, only JSON strings and nulls (e.g.
// "\"a\"" or "null", as tapir passes for JSON codecs) are unwrapped. Otherwise, a string enum such as ["1", "true"]
// would be written as [1, true], as tapir passes enum values as plain strings, not as JSON. The same applies to
// plain-text defaults and examples.
private def encodeSchemaValue(s: Schema, v: ExampleValue): Json = {
val types = s.`type`.getOrElse(Nil)
val isStringSchema = types.filterNot(_ == SchemaType.Null) == List(SchemaType.String)
Expand Down
6 changes: 6 additions & 0 deletions openapi-circe/src/test/resources/spec/3.0/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,12 @@
"example": "a",
"description": "null default and examples"
},
"object default and example": {
"type": "object",
"default": {"a": 1},
"example": {"b": [1, 2]},
"description": "object default and example"
},
"string default and example": {
"type": "string",
"default": "1",
Expand Down
6 changes: 6 additions & 0 deletions openapi-circe/src/test/resources/spec/3.1/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,12 @@
"examples": ["a"],
"description": "null default and examples"
},
"object default and example": {
"type": "object",
"default": {"a": 1},
"examples": [{"b": [1, 2]}],
"description": "object default and example"
},
"string default and example": {
"type": "string",
"default": "1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,14 @@ class DecoderTest extends AnyFunSuite with ResourcePlatform {
val Right(openapi) = readJson("/spec/3.1/schema.json").flatMap(_.as[OpenAPI]): @unchecked
assert(openapi.info.title === "API")
val schemas = openapi.components.getOrElse(Components.Empty).schemas
assert(schemas.size === 24)
assert(schemas.size === 25)
}

test("all schemas types 3.0") {
val Right(openapi) = readJson("/spec/3.0/schema.json").flatMap(_.as[OpenAPI]): @unchecked
assert(openapi.info.title === "API")
val schemas = openapi.components.getOrElse(Components.Empty).schemas
assert(schemas.size === 23)
assert(schemas.size === 24)
}

test("enum, const, default and example values decode to the values used in code") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ object ValueSchemas {
"nullable string enum" -> enumOf(SchemaType.String, "1").nullable,
"string const" -> Schema(SchemaType.String).copy(const = Some(ExampleSingleValue("1"))),
"string default and example" -> Schema(SchemaType.String)
.copy(default = Some(ExampleSingleValue("1")), examples = Some(List(ExampleSingleValue("true"))))
.copy(default = Some(ExampleSingleValue("1")), examples = Some(List(ExampleSingleValue("true")))),
"object default and example" -> Schema(SchemaType.Object).copy(
default = Some(ExampleSingleValue("{\"a\":1}")),
examples = Some(List(ExampleSingleValue("{\"b\":[1,2]}")))
)
).map { case (desc, schema) => desc -> schema.copy(description = Some(desc)) }
}
Loading