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
2 changes: 1 addition & 1 deletion apispec-model/src/main/scala/sttp/apispec/Schema.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
15 changes: 15 additions & 0 deletions apispec-model/src/main/scala/sttp/apispec/model.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions apispec-model/src/test/scala/sttp/apispec/SchemaTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -110,20 +110,13 @@ 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(
_.mapObject(adjustType)
.mapObject(adjustMaximum)
.mapObject(adjustMinimum)
.mapObject(adjustExample)
.mapObject(adjustNullValues)
.mapObject(translateDefinitionsToDefs)
)
decoder.tryDecode(modded).map(s => if (nullable) s.nullable else s)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,19 +63,16 @@ 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)
case _ =>
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,
Expand Down Expand Up @@ -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] =
Expand Down Expand Up @@ -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)
}
}
Expand Down
15 changes: 5 additions & 10 deletions openapi-circe/src/test/resources/spec/3.0/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -110,30 +110,25 @@
"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",
"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],
"description": "boolean enum"
},
"string enum with JSON values": {
"type": "string",
"enum": ["1", "true", "null"],
"enum": ["1", "true"],
"description": "string enum with JSON values"
},
"nullable string enum": {
Expand Down
14 changes: 5 additions & 9 deletions openapi-circe/src/test/resources/spec/3.1/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -124,29 +124,25 @@
"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",
"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],
"description": "boolean enum"
},
"string enum with JSON values": {
"type": "string",
"enum": ["1", "true", "null"],
"enum": ["1", "true"],
"description": "string enum with JSON values"
},
"nullable string enum": {
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 === 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") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)) }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
Loading