From c4cc595ec54e3c3871ddfdda01836ea56b509215 Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Mon, 26 Oct 2026 13:38:25 +0000 Subject: [PATCH 1/5] Decode array and object example values to JSON text, clarify the ExampleValue contract Also compare enum values in SchemaComparator with a function over plain values. --- .../src/main/scala/sttp/apispec/model.scala | 11 +++++------ .../apispec/validation/SchemaComparator.scala | 19 +++++++++++++------ .../validation/SchemaComparatorTest.scala | 4 ++++ .../internal/JsonSchemaCirceDecoders.scala | 6 +++--- .../internal/JsonSchemaCirceEncoders.scala | 5 ++--- .../src/test/resources/spec/3.0/schema.json | 6 ++++++ .../src/test/resources/spec/3.1/schema.json | 6 ++++++ .../apispec/openapi/circe/DecoderTest.scala | 4 ++-- .../apispec/openapi/circe/ValueSchemas.scala | 6 +++++- 9 files changed, 46 insertions(+), 21 deletions(-) diff --git a/apispec-model/src/main/scala/sttp/apispec/model.scala b/apispec-model/src/main/scala/sttp/apispec/model.scala index d5d705c..3b279bd 100644 --- a/apispec-model/src/main/scala/sttp/apispec/model.scala +++ b/apispec-model/src/main/scala/sttp/apispec/model.scala @@ -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`. + * 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` is either plain text (e.g. a query parameter or XML), or a value encoded as JSON (e.g. `"200"`, `"\"a\""` + * or `"{\"a\":1}"`). Text which is valid JSON is written as that JSON value. In a string [[Schema]], this only applies + * to JSON strings and `null`. */ case class ExampleSingleValue(value: Any) extends ExampleValue diff --git a/apispec-model/src/main/scala/sttp/apispec/validation/SchemaComparator.scala b/apispec-model/src/main/scala/sttp/apispec/validation/SchemaComparator.scala index 52baaba..585b233 100644 --- a/apispec-model/src/main/scala/sttp/apispec/validation/SchemaComparator.scala +++ b/apispec-model/src/main/scala/sttp/apispec/validation/SchemaComparator.scala @@ -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] = { @@ -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 _ => diff --git a/apispec-model/src/test/scala/sttp/apispec/validation/SchemaComparatorTest.scala b/apispec-model/src/test/scala/sttp/apispec/validation/SchemaComparatorTest.scala index 56206ef..942a90b 100644 --- a/apispec-model/src/test/scala/sttp/apispec/validation/SchemaComparatorTest.scala +++ b/apispec-model/src/test/scala/sttp/apispec/validation/SchemaComparatorTest.scala @@ -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") { diff --git a/jsonschema-circe/src/main/scala/sttp/apispec/internal/JsonSchemaCirceDecoders.scala b/jsonschema-circe/src/main/scala/sttp/apispec/internal/JsonSchemaCirceDecoders.scala index d77bddd..209f626 100644 --- a/jsonschema-circe/src/main/scala/sttp/apispec/internal/JsonSchemaCirceDecoders.scala +++ b/jsonschema-circe/src/main/scala/sttp/apispec/internal/JsonSchemaCirceDecoders.scala @@ -29,7 +29,7 @@ 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( @@ -37,8 +37,8 @@ trait JsonSchemaCirceDecoders { 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] = diff --git a/jsonschema-circe/src/main/scala/sttp/apispec/internal/JsonSchemaCirceEncoders.scala b/jsonschema-circe/src/main/scala/sttp/apispec/internal/JsonSchemaCirceEncoders.scala index 95ab4a2..bbb53af 100644 --- a/jsonschema-circe/src/main/scala/sttp/apispec/internal/JsonSchemaCirceEncoders.scala +++ b/jsonschema-circe/src/main/scala/sttp/apispec/internal/JsonSchemaCirceEncoders.scala @@ -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) } @@ -157,8 +156,8 @@ 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. + // In string schemas, only JSON strings and nulls (e.g. "\"a\"" or "null", as tapir passes for JSON codecs) are + // unwrapped, so that plain text such as "200" stays a string. private def encodeSchemaValue(s: Schema, v: ExampleValue): Json = { val types = s.`type`.getOrElse(Nil) val isStringSchema = types.filterNot(_ == SchemaType.Null) == List(SchemaType.String) diff --git a/openapi-circe/src/test/resources/spec/3.0/schema.json b/openapi-circe/src/test/resources/spec/3.0/schema.json index 11c6c84..8465cc2 100644 --- a/openapi-circe/src/test/resources/spec/3.0/schema.json +++ b/openapi-circe/src/test/resources/spec/3.0/schema.json @@ -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", diff --git a/openapi-circe/src/test/resources/spec/3.1/schema.json b/openapi-circe/src/test/resources/spec/3.1/schema.json index eb77df7..59d8ac7 100644 --- a/openapi-circe/src/test/resources/spec/3.1/schema.json +++ b/openapi-circe/src/test/resources/spec/3.1/schema.json @@ -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", diff --git a/openapi-circe/src/test/scala/sttp/apispec/openapi/circe/DecoderTest.scala b/openapi-circe/src/test/scala/sttp/apispec/openapi/circe/DecoderTest.scala index 9385edd..206a669 100644 --- a/openapi-circe/src/test/scala/sttp/apispec/openapi/circe/DecoderTest.scala +++ b/openapi-circe/src/test/scala/sttp/apispec/openapi/circe/DecoderTest.scala @@ -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") { diff --git a/openapi-circe/src/test/scala/sttp/apispec/openapi/circe/ValueSchemas.scala b/openapi-circe/src/test/scala/sttp/apispec/openapi/circe/ValueSchemas.scala index 22f0ecc..6a8d2e8 100644 --- a/openapi-circe/src/test/scala/sttp/apispec/openapi/circe/ValueSchemas.scala +++ b/openapi-circe/src/test/scala/sttp/apispec/openapi/circe/ValueSchemas.scala @@ -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)) } } From 583112748cfb5e06d300eb95f074039195c9553f Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Mon, 26 Oct 2026 13:56:34 +0000 Subject: [PATCH 2/5] Explain why string schema values are encoded separately --- .../sttp/apispec/internal/JsonSchemaCirceEncoders.scala | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/jsonschema-circe/src/main/scala/sttp/apispec/internal/JsonSchemaCirceEncoders.scala b/jsonschema-circe/src/main/scala/sttp/apispec/internal/JsonSchemaCirceEncoders.scala index bbb53af..acad814 100644 --- a/jsonschema-circe/src/main/scala/sttp/apispec/internal/JsonSchemaCirceEncoders.scala +++ b/jsonschema-circe/src/main/scala/sttp/apispec/internal/JsonSchemaCirceEncoders.scala @@ -156,8 +156,9 @@ trait JsonSchemaCirceEncoders { case e: ExampleSingleValue => encoderExampleSingleValue.apply(e) } - // In string schemas, only JSON strings and nulls (e.g. "\"a\"" or "null", as tapir passes for JSON codecs) are - // unwrapped, so that plain text such as "200" stays a string. + // 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 raw enum values. 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) From f7593fdd8e8c811599a5340e7f54aa77479092a3 Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Mon, 26 Oct 2026 14:13:15 +0000 Subject: [PATCH 3/5] Describe example values as document values, optionally given as encoded text --- apispec-model/src/main/scala/sttp/apispec/model.scala | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apispec-model/src/main/scala/sttp/apispec/model.scala b/apispec-model/src/main/scala/sttp/apispec/model.scala index 3b279bd..c34fdbb 100644 --- a/apispec-model/src/main/scala/sttp/apispec/model.scala +++ b/apispec-model/src/main/scala/sttp/apispec/model.scala @@ -6,12 +6,12 @@ import scala.collection.immutable.ListMap sealed trait ExampleValue /** @param value - * A `Boolean`, `null`, number (`Int`, `Long`, `Short`, `Float`, `Double`, `BigDecimal` or `BigInt`) or `String`. - * Other values 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` is either plain text (e.g. a query parameter or XML), or a value encoded as JSON (e.g. `"200"`, `"\"a\""` - * or `"{\"a\":1}"`). Text which is valid JSON is written as that JSON value. In a string [[Schema]], this only applies - * to JSON strings and `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 nested in values, are given as JSON text. */ case class ExampleSingleValue(value: Any) extends ExampleValue From 98c8863bdeb43b48e4ecd3979a80dd8a91d9c3cd Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Mon, 26 Oct 2026 14:17:23 +0000 Subject: [PATCH 4/5] Clarify how objects and arrays are given as example values --- apispec-model/src/main/scala/sttp/apispec/model.scala | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apispec-model/src/main/scala/sttp/apispec/model.scala b/apispec-model/src/main/scala/sttp/apispec/model.scala index c34fdbb..4ab4b4d 100644 --- a/apispec-model/src/main/scala/sttp/apispec/model.scala +++ b/apispec-model/src/main/scala/sttp/apispec/model.scala @@ -10,8 +10,8 @@ sealed trait ExampleValue * `BigDecimal` or `BigInt`) or `String`. Other values are written as strings, using `toString`. * * 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 nested in values, are given as JSON 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 From 7e012412cf0915add660ddc619177bd23530ff16 Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Mon, 26 Oct 2026 14:23:00 +0000 Subject: [PATCH 5/5] Say that tapir passes enum values as plain strings --- .../scala/sttp/apispec/internal/JsonSchemaCirceEncoders.scala | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/jsonschema-circe/src/main/scala/sttp/apispec/internal/JsonSchemaCirceEncoders.scala b/jsonschema-circe/src/main/scala/sttp/apispec/internal/JsonSchemaCirceEncoders.scala index acad814..2727e51 100644 --- a/jsonschema-circe/src/main/scala/sttp/apispec/internal/JsonSchemaCirceEncoders.scala +++ b/jsonschema-circe/src/main/scala/sttp/apispec/internal/JsonSchemaCirceEncoders.scala @@ -158,7 +158,8 @@ trait JsonSchemaCirceEncoders { // 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 raw enum values. The same applies to plain-text defaults and examples. + // 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)