From 5d6fbeee763681246b3efda55b1b612097f3cdec Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Sun, 25 Oct 2026 19:14:20 +0000 Subject: [PATCH] Keep string defaults and examples as strings, and keep null const and default - in string schemas, write default and examples values as strings, as for enum and const; JSON-encoded strings (e.g. "\"a\"", as tapir passes for JSON codecs) are unwrapped - decode a null const or default as "null" instead of dropping it, and write it back as null --- .../internal/JsonSchemaCirceDecoders.scala | 7 ++++ .../internal/JsonSchemaCirceEncoders.scala | 32 ++++++++++++------- .../src/test/resources/spec/3.0/schema.json | 22 +++++++++++++ .../src/test/resources/spec/3.1/schema.json | 21 ++++++++++++ .../apispec/openapi/circe/DecoderTest.scala | 8 ++--- .../{EnumSchemas.scala => ValueSchemas.scala} | 10 ++++-- .../openapi/circe/threeone/EncoderTest.scala | 10 ++++-- 7 files changed, 89 insertions(+), 21 deletions(-) rename openapi-circe/src/test/scala/sttp/apispec/openapi/circe/{EnumSchemas.scala => ValueSchemas.scala} (57%) 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 aa982bc..3b4a3a2 100644 --- a/jsonschema-circe/src/main/scala/sttp/apispec/internal/JsonSchemaCirceDecoders.scala +++ b/jsonschema-circe/src/main/scala/sttp/apispec/internal/JsonSchemaCirceDecoders.scala @@ -110,6 +110,12 @@ trait JsonSchemaCirceDecoders { case _ => obj } + // A null const or default would decode as None. It's a value, decoded as "null" like null in other values. + def adjustNullValues(obj: JsonObject): JsonObject = + List("const", "default").foldLeft(obj) { (o, key) => + if (o(key).exists(_.isNull)) o.add(key, Json.fromString("null")) else o + } + def adjustSyntax(decoder: Decoder[Schema]) = Decoder.instance { c => val nullable = c.get[Boolean]("nullable").contains(true) val modded = c.withFocus( @@ -117,6 +123,7 @@ trait JsonSchemaCirceDecoders { .mapObject(adjustMaximum) .mapObject(adjustMinimum) .mapObject(adjustExample) + .mapObject(adjustNullValues) .mapObject(translateDefinitionsToDefs) ) decoder.tryDecode(modded).map(s => if (nullable) s.nullable else s) 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 e5c2467..e3d2d89 100644 --- a/jsonschema-circe/src/main/scala/sttp/apispec/internal/JsonSchemaCirceEncoders.scala +++ b/jsonschema-circe/src/main/scala/sttp/apispec/internal/JsonSchemaCirceEncoders.scala @@ -22,8 +22,8 @@ trait JsonSchemaCirceEncoders { val nullSchema = Schema(`type` = Some(List(SchemaType.Null))) // In OpenAPI 3.0 the keyword "const" is not allowed and needs to be replaced by an "enum" with one element - val enumJson = s.`enum`.map(_.map(encodeEnumValue(s, _))) - val constJson = s.const.map(encodeEnumValue(s, _)) + val enumJson = s.`enum`.map(_.map(encodeSchemaValue(s, _))) + val constJson = s.const.map(encodeSchemaValue(s, _)) val enumAndConstFields = if (openApi30 && constJson.isDefined) Vector("enum" := constJson.map(List(_)), "const" := None) @@ -63,14 +63,19 @@ trait JsonSchemaCirceEncoders { Vector("maximum" := s.maximum, "exclusiveMaximum" := s.exclusiveMaximum) } - val exampleFields = s.examples match { + val examplesJson = s.examples.map(_.map(encodeSchemaValue(s, _))) + val exampleFields = examplesJson match { case Some(List(example)) if openApi30 => Vector("example" := example) case _ => - Vector("examples" := s.examples) + Vector("examples" := examplesJson) } - JsonObject.fromIterable( + // Unlike other null fields, a null const or default is a value, which is kept + val keptNullFields = + Set("default").filter(_ => s.default.isDefined) ++ Set("const").filter(_ => s.const.isDefined && !openApi30) + + val fields = JsonObject.fromIterable( Vector( "$schema" := s.$schema, "$vocabulary" := s.$vocabulary, @@ -83,7 +88,7 @@ trait JsonSchemaCirceEncoders { "$defs" := s.$defs, "title" := s.title, "description" := s.description, - "default" := s.default, + "default" := s.default.map(encodeSchemaValue(s, _)), "deprecated" := s.deprecated, "readOnly" := s.readOnly, "writeOnly" := s.writeOnly @@ -125,7 +130,8 @@ trait JsonSchemaCirceEncoders { "extensions" := s.extensions ) ) - }.dropNullsExpandExtensions + expandExtensions(fields.filter { case (k, v) => !v.isNull || keptNullFields(k) }) + } // note: these are strict val-s, order matters! implicit val extensionValue: Encoder[ExtensionValue] = @@ -155,16 +161,18 @@ trait JsonSchemaCirceEncoders { case e: ExampleSingleValue => encoderExampleSingleValue.apply(e) } - // Strings in string schemas are written as-is; elsewhere they are parsed as JSON (so "200" becomes 200). In nullable - // schemas, "null" is the JSON null added by Schema.nullable. - private def encodeEnumValue(s: Schema, v: ExampleValue): Json = { + // Strings are parsed as JSON (so "200" becomes 200), except in string schemas: there, only JSON-encoded strings + // (e.g. "\"a\"", as tapir passes for JSON codecs) are unwrapped. In nullable schemas, "null" is the JSON null added by + // Schema.nullable. + private def encodeSchemaValue(s: Schema, v: ExampleValue): Json = { val types = s.`type`.getOrElse(Nil) val isNullable = types.contains(SchemaType.Null) val isStringSchema = types.filterNot(_ == SchemaType.Null) == List(SchemaType.String) v match { case ExampleSingleValue("null") if isNullable => Json.Null - case ExampleSingleValue(value: String) if isStringSchema => Json.fromString(value) - case _ => encoderExampleValue(v) + case ExampleSingleValue(value: String) if isStringSchema => + parse(value).toOption.filter(_.isString).getOrElse(Json.fromString(value)) + case _ => encoderExampleValue(v) } } 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 ea2ab86..54c3f16 100644 --- a/openapi-circe/src/test/resources/spec/3.0/schema.json +++ b/openapi-circe/src/test/resources/spec/3.0/schema.json @@ -104,6 +104,28 @@ "enum": [200], "description": "integer enum from strings" }, + "JSON-encoded string values": { + "type": "string", + "default": "a", + "enum": ["a"], + "description": "JSON-encoded string values" + }, + "null const": { + "enum": [null], + "description": "null const" + }, + "string default and example": { + "type": "string", + "default": "1", + "example": "true", + "description": "string default and example" + }, + "nullable default null": { + "type": "string", + "nullable": true, + "default": null, + "description": "nullable default null" + }, "boolean enum": { "type": "boolean", "enum": [true], 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 06aa7a6..8dd8b09 100644 --- a/openapi-circe/src/test/resources/spec/3.1/schema.json +++ b/openapi-circe/src/test/resources/spec/3.1/schema.json @@ -118,6 +118,27 @@ "enum": [200], "description": "integer enum from strings" }, + "JSON-encoded string values": { + "type": "string", + "default": "a", + "enum": ["a"], + "description": "JSON-encoded string values" + }, + "null const": { + "const": null, + "description": "null const" + }, + "string default and example": { + "type": "string", + "default": "1", + "examples": ["true"], + "description": "string default and example" + }, + "nullable default null": { + "type": ["string", "null"], + "default": null, + "description": "nullable default null" + }, "boolean enum": { "type": "boolean", "enum": [true], 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 8640d2a..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,24 +42,24 @@ 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 === 21) + 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 === 20) + assert(schemas.size === 24) } - test("enum and const values decode to the values used in code") { + test("enum, const, default and example values decode to the values used in code") { def decodedSchemas(file: String) = { val Right(openapi) = readJson(file).flatMap(_.as[OpenAPI]): @unchecked openapi.components.getOrElse(Components.Empty).schemas } val schemas31 = decodedSchemas("/spec/3.1/schema.json") val schemas30 = decodedSchemas("/spec/3.0/schema.json") - EnumSchemas.all.foreach { case (name, schema) => + ValueSchemas.all.foreach { case (name, schema) => assert(schemas31(name) == schema, name) // 3.0 has no const, so it's encoded as an enum if (schema.const.isEmpty) assert(schemas30(name) == schema, name) diff --git a/openapi-circe/src/test/scala/sttp/apispec/openapi/circe/EnumSchemas.scala b/openapi-circe/src/test/scala/sttp/apispec/openapi/circe/ValueSchemas.scala similarity index 57% rename from openapi-circe/src/test/scala/sttp/apispec/openapi/circe/EnumSchemas.scala rename to openapi-circe/src/test/scala/sttp/apispec/openapi/circe/ValueSchemas.scala index 932b7b7..f932f6d 100644 --- a/openapi-circe/src/test/scala/sttp/apispec/openapi/circe/EnumSchemas.scala +++ b/openapi-circe/src/test/scala/sttp/apispec/openapi/circe/ValueSchemas.scala @@ -4,8 +4,8 @@ import sttp.apispec._ import scala.collection.immutable.ListMap -/** In-code versions of the enum and const schemas from the `spec/3.x/schema.json` fixtures. */ -object EnumSchemas { +/** In-code versions of the enum, const, default and example schemas from the `spec/3.x/schema.json` fixtures. */ +object ValueSchemas { private def enumOf(tpe: SchemaType, values: Any*): Schema = Schema(tpe).copy(`enum` = Some(values.toList.map(ExampleSingleValue(_)))) @@ -14,6 +14,10 @@ object EnumSchemas { "boolean enum" -> enumOf(SchemaType.Boolean, true), "string enum with JSON values" -> enumOf(SchemaType.String, "1", "true", "null"), "nullable string enum" -> enumOf(SchemaType.String, "1").nullable, - "string const" -> Schema(SchemaType.String).copy(const = Some(ExampleSingleValue("1"))) + "string const" -> Schema(SchemaType.String).copy(const = Some(ExampleSingleValue("1"))), + "null const" -> Schema(const = Some(ExampleSingleValue("null"))), + "string default and example" -> Schema(SchemaType.String) + .copy(default = Some(ExampleSingleValue("1")), examples = Some(List(ExampleSingleValue("true")))), + "nullable default null" -> Schema(SchemaType.String).nullable.copy(default = Some(ExampleSingleValue("null"))) ).map { case (desc, schema) => desc -> schema.copy(description = Some(desc)) } } diff --git a/openapi-circe/src/test/scala/sttp/apispec/openapi/circe/threeone/EncoderTest.scala b/openapi-circe/src/test/scala/sttp/apispec/openapi/circe/threeone/EncoderTest.scala index 9c3987a..7f20fab 100644 --- a/openapi-circe/src/test/scala/sttp/apispec/openapi/circe/threeone/EncoderTest.scala +++ b/openapi-circe/src/test/scala/sttp/apispec/openapi/circe/threeone/EncoderTest.scala @@ -4,7 +4,7 @@ import io.circe.syntax._ import org.scalatest.funsuite.AnyFunSuite import sttp.apispec._ import sttp.apispec.openapi._ -import sttp.apispec.openapi.circe.EnumSchemas +import sttp.apispec.openapi.circe.ValueSchemas import sttp.apispec.test._ import scala.collection.immutable.ListMap @@ -127,8 +127,14 @@ class EncoderTest extends AnyFunSuite with ResourcePlatform { schemaComponent("enum")(Schema(`enum` = Some(List("enum1", "enum2").map(ExampleSingleValue(_))))), schemaComponent("integer enum from strings")( Schema(SchemaType.Integer).copy(`enum` = Some(List(ExampleSingleValue("200")))) + ), + schemaComponent("JSON-encoded string values")( + Schema(SchemaType.String).copy( + default = Some(ExampleSingleValue("\"a\"")), + `enum` = Some(List(ExampleSingleValue("\"a\""))) + ) ) - ) ++ EnumSchemas.all + ) ++ ValueSchemas.all ) OpenAPI(