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
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,20 @@ 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 @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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] =
Expand Down Expand Up @@ -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)
}
}

Expand Down
22 changes: 22 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 @@ -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],
Expand Down
21 changes: 21 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 @@ -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],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(_))))

Expand All @@ -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)) }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Loading