diff --git a/apispec-model/src/main/scala/sttp/apispec/Schema.scala b/apispec-model/src/main/scala/sttp/apispec/Schema.scala index 8d02001..2bde620 100644 --- a/apispec-model/src/main/scala/sttp/apispec/Schema.scala +++ b/apispec-model/src/main/scala/sttp/apispec/Schema.scala @@ -132,7 +132,7 @@ case class Schema( */ def nullable: Schema = `type` match { case Some(types) => - val NullExample = ExampleSingleValue("null") + val NullExample = ExampleSingleValue(null) if (types.contains(SchemaType.Null)) this // ensure idempotency else copy( diff --git a/apispec-model/src/main/scala/sttp/apispec/model.scala b/apispec-model/src/main/scala/sttp/apispec/model.scala index 5044de7..d5d705c 100644 --- a/apispec-model/src/main/scala/sttp/apispec/model.scala +++ b/apispec-model/src/main/scala/sttp/apispec/model.scala @@ -2,8 +2,23 @@ package sttp.apispec import scala.collection.immutable.ListMap +/** A value of an example, default, enum or const, as written in the JSON or YAML document. */ 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 `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"`). + */ case class ExampleSingleValue(value: Any) extends ExampleValue + +/** @param values + * Values, as in [[ExampleSingleValue]]. + */ case class ExampleMultipleValue(values: List[Any]) extends ExampleValue case class Tag( diff --git a/apispec-model/src/test/scala/sttp/apispec/SchemaTest.scala b/apispec-model/src/test/scala/sttp/apispec/SchemaTest.scala index 4fe4f52..3dcebb0 100644 --- a/apispec-model/src/test/scala/sttp/apispec/SchemaTest.scala +++ b/apispec-model/src/test/scala/sttp/apispec/SchemaTest.scala @@ -27,7 +27,7 @@ class SchemaTest extends AnyFunSuite { assert( schema.nullable == Schema( `type` = Some(List(SchemaType.String, SchemaType.Null)), - `enum` = Some(List("a", "b", "null").map(ExampleSingleValue(_))) + `enum` = Some(List("a", "b", null).map(ExampleSingleValue(_))) ) ) assert(schema.nullable.nullable == schema.nullable) // idempotency @@ -38,7 +38,7 @@ class SchemaTest extends AnyFunSuite { assert( schema.nullable == Schema( `type` = Some(List(SchemaType.String, SchemaType.Null)), - `enum` = Some(List("a", "null").map(ExampleSingleValue(_))) + `enum` = Some(List("a", null).map(ExampleSingleValue(_))) ) ) assert(schema.nullable.nullable == schema.nullable) 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 3b4a3a2..d77bddd 100644 --- a/jsonschema-circe/src/main/scala/sttp/apispec/internal/JsonSchemaCirceDecoders.scala +++ b/jsonschema-circe/src/main/scala/sttp/apispec/internal/JsonSchemaCirceDecoders.scala @@ -29,11 +29,11 @@ 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. `null` becomes the `"null"` string, as in [[Schema.nullable]]. Arrays and objects stay as [[Json]]. + * equal. Arrays and objects stay as [[Json]]. */ private def decodeExampleValue(json: Json): Any = json.fold( - jsonNull = "null", + jsonNull = null, jsonBoolean = b => b, jsonNumber = n => n.toInt.orElse(n.toLong).orElse(n.toBigDecimal).getOrElse(n.toDouble), jsonString = s => s, @@ -110,12 +110,6 @@ 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( @@ -123,7 +117,6 @@ 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 e3d2d89..95ab4a2 100644 --- a/jsonschema-circe/src/main/scala/sttp/apispec/internal/JsonSchemaCirceEncoders.scala +++ b/jsonschema-circe/src/main/scala/sttp/apispec/internal/JsonSchemaCirceEncoders.scala @@ -63,7 +63,8 @@ trait JsonSchemaCirceEncoders { Vector("maximum" := s.maximum, "exclusiveMaximum" := s.exclusiveMaximum) } - val examplesJson = s.examples.map(_.map(encodeSchemaValue(s, _))) + // Null examples are dropped, like other null fields + val examplesJson = s.examples.map(_.map(encodeSchemaValue(s, _)).filterNot(_.isNull)) val exampleFields = examplesJson match { case Some(List(example)) if openApi30 => Vector("example" := example) @@ -71,11 +72,7 @@ trait JsonSchemaCirceEncoders { Vector("examples" := examplesJson) } - // 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( + JsonObject.fromIterable( Vector( "$schema" := s.$schema, "$vocabulary" := s.$vocabulary, @@ -130,8 +127,7 @@ trait JsonSchemaCirceEncoders { "extensions" := s.extensions ) ) - expandExtensions(fields.filter { case (k, v) => !v.isNull || keptNullFields(k) }) - } + }.dropNullsExpandExtensions // note: these are strict val-s, order matters! implicit val extensionValue: Encoder[ExtensionValue] = @@ -161,17 +157,14 @@ 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 - // (e.g. "\"a\"", as tapir passes for JSON codecs) are unwrapped. In nullable schemas, "null" is the JSON null added by - // Schema.nullable. + // 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. 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 => - parse(value).toOption.filter(_.isString).getOrElse(Json.fromString(value)) + parse(value).toOption.filter(j => j.isString || j.isNull).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 54c3f16..11c6c84 100644 --- a/openapi-circe/src/test/resources/spec/3.0/schema.json +++ b/openapi-circe/src/test/resources/spec/3.0/schema.json @@ -110,9 +110,10 @@ "enum": ["a"], "description": "JSON-encoded string values" }, - "null const": { - "enum": [null], - "description": "null const" + "null default and examples": { + "type": "string", + "example": "a", + "description": "null default and examples" }, "string default and example": { "type": "string", @@ -120,12 +121,6 @@ "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], @@ -133,7 +128,7 @@ }, "string enum with JSON values": { "type": "string", - "enum": ["1", "true", "null"], + "enum": ["1", "true"], "description": "string enum with JSON values" }, "nullable string enum": { 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 8dd8b09..eb77df7 100644 --- a/openapi-circe/src/test/resources/spec/3.1/schema.json +++ b/openapi-circe/src/test/resources/spec/3.1/schema.json @@ -124,9 +124,10 @@ "enum": ["a"], "description": "JSON-encoded string values" }, - "null const": { - "const": null, - "description": "null const" + "null default and examples": { + "type": "string", + "examples": ["a"], + "description": "null default and examples" }, "string default and example": { "type": "string", @@ -134,11 +135,6 @@ "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], @@ -146,7 +142,7 @@ }, "string enum with JSON values": { "type": "string", - "enum": ["1", "true", "null"], + "enum": ["1", "true"], "description": "string enum with JSON values" }, "nullable string enum": { 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 206a669..9385edd 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 === 25) + assert(schemas.size === 24) } 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 === 24) + assert(schemas.size === 23) } 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 f932f6d..22f0ecc 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 @@ -12,12 +12,10 @@ object ValueSchemas { val all: ListMap[String, Schema] = ListMap( "integer enum" -> enumOf(SchemaType.Integer, 200, 404), "boolean enum" -> enumOf(SchemaType.Boolean, true), - "string enum with JSON values" -> enumOf(SchemaType.String, "1", "true", "null"), + "string enum with JSON values" -> enumOf(SchemaType.String, "1", "true"), "nullable string enum" -> enumOf(SchemaType.String, "1").nullable, "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"))) + .copy(default = Some(ExampleSingleValue("1")), examples = Some(List(ExampleSingleValue("true")))) ).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 7f20fab..d79dc30 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 @@ -133,6 +133,12 @@ class EncoderTest extends AnyFunSuite with ResourcePlatform { default = Some(ExampleSingleValue("\"a\"")), `enum` = Some(List(ExampleSingleValue("\"a\""))) ) + ), + schemaComponent("null default and examples")( + Schema(SchemaType.String).copy( + default = Some(ExampleSingleValue("null")), + examples = Some(List(ExampleSingleValue(null), ExampleSingleValue("a"))) + ) ) ) ++ ValueSchemas.all )