Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Not dropping too many null values in OpenAPI encoders #207

Merged
merged 8 commits into from
Jan 27, 2025
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
7 changes: 6 additions & 1 deletion apispec-model/src/main/scala/sttp/apispec/Schema.scala
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,13 @@ case class Schema(
*/
def nullable: Schema = `type` match {
case Some(types) =>
val NullExample = ExampleSingleValue("null")
if (types.contains(SchemaType.Null)) this // ensure idempotency
else copy(`type` = Some(types :+ SchemaType.Null))
else copy(
`type` = Some(types :+ SchemaType.Null),
`enum` = `enum`.orElse(`const`.map(List(_))).map(vs => (vs :+ NullExample).distinct),
`const` = None
)

case None =>
// Representing nullable schemas (without explicit `type`) using `anyOf` is safer than `oneOf`.
Expand Down
18 changes: 18 additions & 0 deletions apispec-model/src/test/scala/sttp/apispec/SchemaTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,22 @@ class SchemaTest extends AnyFunSuite {
assert(schema.nullable == Schema(anyOf = List(Schema(SchemaType.String), Schema(SchemaType.Number), Schema.Null)))
assert(schema.nullable.nullable == schema.nullable) // idempotency
}

test("nullable enum") {
val schema = Schema(`type` = Some(List(SchemaType.String)), `enum` = Some(List("a", "b").map(ExampleSingleValue(_))))
assert(schema.nullable == Schema(
`type` = Some(List(SchemaType.String, SchemaType.Null)),
`enum` = Some(List("a", "b", "null").map(ExampleSingleValue(_)))
))
assert(schema.nullable.nullable == schema.nullable) // idempotency
}

test("nullable const") {
val schema = Schema(`type` = Some(List(SchemaType.String)), `const` = Some(ExampleSingleValue("a")))
assert(schema.nullable == Schema(
`type` = Some(List(SchemaType.String, SchemaType.Null)),
`enum` = Some(List("a", "null").map(ExampleSingleValue(_)))
))
assert(schema.nullable.nullable == schema.nullable)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -217,11 +217,11 @@ abstract class SchemaComparatorTest(referencePrefix: String) extends AnyFunSuite
}

private def enums(values: Any*): List[ExampleSingleValue] =
values.toList.map(ExampleSingleValue)
values.toList.map(ExampleSingleValue(_))

private def enumSchema(values: String*): Schema = values.toList match {
case single :: Nil => stringSchema.copy(`enum` = Some(List(single).map(ExampleSingleValue)))
case multiple => stringSchema.copy(`enum` = Some(multiple.map(ExampleSingleValue)))
case single :: Nil => stringSchema.copy(`enum` = Some(List(single).map(ExampleSingleValue(_))))
case multiple => stringSchema.copy(`enum` = Some(multiple.map(ExampleSingleValue(_))))
}

test("compatible enum & const") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,19 @@ import io.circe._
import io.circe.generic.semiauto.deriveEncoder
import io.circe.parser.parse
import io.circe.syntax._
import sttp.apispec.internal.JsonSchemaCirceEncoders.ObjectEncoderOps

import scala.collection.immutable.ListMap
import scala.language.implicitConversions

trait JsonSchemaCirceEncoders {
def anyObjectEncoding: AnySchema.Encoding

def openApi30: Boolean = false

protected final implicit def objectEncoderOps[T](encoder: Encoder.AsObject[T]): ObjectEncoderOps[T] =
new ObjectEncoderOps(encoder)

implicit lazy val encoderSchema: Encoder[Schema] = Encoder.AsObject
.instance { (s: Schema) =>
val nullSchema = Schema(`type` = Some(List(SchemaType.Null)))
Expand Down Expand Up @@ -115,7 +120,7 @@ trait JsonSchemaCirceEncoders {
)
)
}
.mapJsonObject(expandExtensions)
.dropNullsExpandExtensions

// note: these are strict val-s, order matters!
implicit val extensionValue: Encoder[ExtensionValue] =
Expand Down Expand Up @@ -153,10 +158,10 @@ trait JsonSchemaCirceEncoders {
Encoder.encodeString.contramap(_.value)

implicit val encoderDiscriminator: Encoder[Discriminator] =
deriveEncoder[Discriminator]
deriveEncoder[Discriminator].dropNulls

implicit val encoderExternalDocumentation: Encoder[ExternalDocumentation] =
deriveEncoder[ExternalDocumentation].mapJsonObject(expandExtensions)
deriveEncoder[ExternalDocumentation].dropNullsExpandExtensions

implicit val encoderAnySchema: Encoder[AnySchema] = Encoder.instance {
case AnySchema.Anything =>
Expand Down Expand Up @@ -191,6 +196,20 @@ trait JsonSchemaCirceEncoders {
Json.obj(properties: _*)
}

// just for backward compatibility
private[apispec] def expandExtensions(jsonObject: JsonObject): JsonObject =
JsonSchemaCirceEncoders.expandExtensions(jsonObject)

}
object JsonSchemaCirceEncoders {
class ObjectEncoderOps[T](private val encoder: Encoder.AsObject[T]) extends AnyVal {
def dropNulls: Encoder.AsObject[T] =
encoder.mapJsonObject(_.filter { case (_, v) => !v.isNull })

def dropNullsExpandExtensions: Encoder.AsObject[T] =
dropNulls.mapJsonObject(expandExtensions)
}

/*
Openapi extensions are arbitrary key-value data that could be added to some of models in specifications, such
as `OpenAPI` itself, `License`, `Parameter`, etc.
Expand Down Expand Up @@ -221,7 +240,7 @@ trait JsonSchemaCirceEncoders {
x-foo: 42
```
*/
private[apispec] def expandExtensions(jsonObject: JsonObject): JsonObject = {
private def expandExtensions(jsonObject: JsonObject): JsonObject = {
val jsonWithoutExt = jsonObject.filterKeys(_ != "extensions")
jsonObject("extensions")
.flatMap(_.asObject)
Expand All @@ -230,11 +249,10 @@ trait JsonSchemaCirceEncoders {
allKeys.foldLeft(JsonObject.empty) { case (acc, key) =>
extObject(key).orElse(jsonWithoutExt(key)) match {
case Some(value) => acc.add(key, value)
case None => acc
case None => acc
}
}
}
.getOrElse(jsonWithoutExt)
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import sttp.apispec.internal.JsonSchemaCirceEncoders
import scala.collection.immutable.ListMap

trait InternalSttpOpenAPICirceEncoders extends JsonSchemaCirceEncoders {
implicit val encoderReference: Encoder[Reference] = deriveEncoder[Reference]
implicit val encoderReference: Encoder[Reference] = deriveEncoder[Reference].dropNulls
implicit def encoderReferenceOr[T: Encoder]: Encoder[ReferenceOr[T]] = {
case Left(Reference(ref, summary, description)) =>
Json
Expand All @@ -27,25 +27,25 @@ trait InternalSttpOpenAPICirceEncoders extends JsonSchemaCirceEncoders {
// #79: all OAuth flow object MUST include a scopes field, but it MAY be empty.
implicit def encodeListMap: Encoder[ListMap[String, String]] = doEncodeListMap(nullWhenEmpty = false)

deriveEncoder[OAuthFlow].mapJsonObject(expandExtensions)
deriveEncoder[OAuthFlow].dropNullsExpandExtensions
}
implicit val encoderOAuthFlows: Encoder[OAuthFlows] = deriveEncoder[OAuthFlows].mapJsonObject(expandExtensions)
implicit val encoderOAuthFlows: Encoder[OAuthFlows] = deriveEncoder[OAuthFlows].dropNullsExpandExtensions
implicit val encoderSecurityScheme: Encoder[SecurityScheme] =
deriveEncoder[SecurityScheme].mapJsonObject(expandExtensions)
deriveEncoder[SecurityScheme].dropNullsExpandExtensions

implicit val encoderHeader: Encoder[Header] = deriveEncoder[Header]
implicit val encoderExample: Encoder[Example] = deriveEncoder[Example].mapJsonObject(expandExtensions)
implicit val encoderResponse: Encoder[Response] = deriveEncoder[Response].mapJsonObject(expandExtensions)
implicit val encoderLink: Encoder[Link] = deriveEncoder[Link].mapJsonObject(expandExtensions)
implicit val encoderExample: Encoder[Example] = deriveEncoder[Example].dropNullsExpandExtensions
implicit val encoderResponse: Encoder[Response] = deriveEncoder[Response].dropNullsExpandExtensions
implicit val encoderLink: Encoder[Link] = deriveEncoder[Link].dropNullsExpandExtensions
implicit val encoderCallback: Encoder[Callback] = Encoder.instance { callback =>
Json.obj(callback.pathItems.map { case (path, pathItem) => path -> pathItem.asJson }.toList: _*)
}
implicit val encoderEncoding: Encoder[Encoding] = deriveEncoder[Encoding].mapJsonObject(expandExtensions)
implicit val encoderMediaType: Encoder[MediaType] = deriveEncoder[MediaType].mapJsonObject(expandExtensions)
implicit val encoderRequestBody: Encoder[RequestBody] = deriveEncoder[RequestBody].mapJsonObject(expandExtensions)
implicit val encoderEncoding: Encoder[Encoding] = deriveEncoder[Encoding].dropNullsExpandExtensions
implicit val encoderMediaType: Encoder[MediaType] = deriveEncoder[MediaType].dropNullsExpandExtensions
implicit val encoderRequestBody: Encoder[RequestBody] = deriveEncoder[RequestBody].dropNullsExpandExtensions
implicit val encoderParameterStyle: Encoder[ParameterStyle] = { e => Encoder.encodeString(e.value) }
implicit val encoderParameterIn: Encoder[ParameterIn] = { e => Encoder.encodeString(e.value) }
implicit val encoderParameter: Encoder[Parameter] = deriveEncoder[Parameter].mapJsonObject(expandExtensions)
implicit val encoderParameter: Encoder[Parameter] = deriveEncoder[Parameter].dropNullsExpandExtensions
implicit val encoderResponseMap: Encoder[ListMap[ResponsesKey, ReferenceOr[Response]]] =
(responses: ListMap[ResponsesKey, ReferenceOr[Response]]) => {
val fields = responses.map {
Expand All @@ -70,22 +70,21 @@ trait InternalSttpOpenAPICirceEncoders extends JsonSchemaCirceEncoders {
implicit def encodeListMapForCallbacks: Encoder[ListMap[String, ReferenceOr[Callback]]] =
doEncodeListMap(nullWhenEmpty = true)

deriveEncoder[Operation].mapJsonObject(expandExtensions)
deriveEncoder[Operation].dropNullsExpandExtensions
}
implicit val encoderPathItem: Encoder[PathItem] = deriveEncoder[PathItem].mapJsonObject(expandExtensions)
implicit val encoderPathItem: Encoder[PathItem] = deriveEncoder[PathItem].dropNullsExpandExtensions
implicit val encoderPaths: Encoder[Paths] = Encoder.instance { paths =>
val extensions = paths.extensions.asJsonObject
val pathItems = paths.pathItems.asJson
pathItems.asObject.map(_.deepMerge(extensions).asJson).getOrElse(pathItems)
}
implicit val encoderComponents: Encoder[Components] = deriveEncoder[Components].mapJsonObject(expandExtensions)
implicit val encoderComponents: Encoder[Components] = deriveEncoder[Components].dropNullsExpandExtensions
implicit val encoderServerVariable: Encoder[ServerVariable] =
deriveEncoder[ServerVariable].mapJsonObject(expandExtensions)
implicit val encoderServer: Encoder[Server] = deriveEncoder[Server].mapJsonObject(expandExtensions)
implicit val encoderTag: Encoder[Tag] = deriveEncoder[Tag].mapJsonObject(expandExtensions)
implicit val encoderInfo: Encoder[Info] = deriveEncoder[Info].mapJsonObject(expandExtensions)
implicit val encoderContact: Encoder[Contact] = deriveEncoder[Contact].mapJsonObject(expandExtensions)
implicit val encoderLicense: Encoder[License] = deriveEncoder[License].mapJsonObject(expandExtensions)
implicit val encoderOpenAPI: Encoder[OpenAPI] =
deriveEncoder[OpenAPI].mapJsonObject(expandExtensions).mapJson(_.deepDropNullValues)
deriveEncoder[ServerVariable].dropNullsExpandExtensions
implicit val encoderServer: Encoder[Server] = deriveEncoder[Server].dropNullsExpandExtensions
implicit val encoderTag: Encoder[Tag] = deriveEncoder[Tag].dropNullsExpandExtensions
implicit val encoderInfo: Encoder[Info] = deriveEncoder[Info].dropNullsExpandExtensions
implicit val encoderContact: Encoder[Contact] = deriveEncoder[Contact].dropNullsExpandExtensions
implicit val encoderLicense: Encoder[License] = deriveEncoder[License].dropNullsExpandExtensions
implicit val encoderOpenAPI: Encoder[OpenAPI] = deriveEncoder[OpenAPI].dropNullsExpandExtensions
}

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"type" : "oauth2",
"flows" : {
"clientCredentials" : {
"tokenUrl" : "openapi-circe-token",
"scopes" : {

}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"type" : "oauth2",
"flows" : {
"clientCredentials" : {
"tokenUrl" : "openapi-circe-token",
"scopes" : {
"example" : "description"
}
}
}
}
18 changes: 17 additions & 1 deletion openapi-circe/src/test/resources/spec/3.0/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@
"nullable": true,
"description": "nullable string"
},
"nullable enum" : {
"description" : "nullable enum",
"enum" : [
"a",
"b",
null
]
},
"nullable reference": {
"nullable": true,
"allOf": [
Expand All @@ -37,6 +45,14 @@
"ex1"
]
},
"object with example" : {
"description" : "object with example",
"example" : {
"a" : 1,
"b" : null
},
"type" : "object"
},
"min/max": {
"minimum": 10,
"maximum": 20,
Expand Down Expand Up @@ -67,4 +83,4 @@
}
}
}
}
}
20 changes: 19 additions & 1 deletion openapi-circe/src/test/resources/spec/3.1/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@
],
"description": "nullable string"
},
"nullable enum" : {
"description" : "nullable enum",
"enum" : [
"a",
"b",
null
]
},
"nullable reference": {
"anyOf": [
{
Expand Down Expand Up @@ -45,6 +53,16 @@
]
]
},
"object with example" : {
"description" : "object with example",
"examples" : [
{
"a" : 1,
"b" : null
}
],
"type" : "object"
},
"min/max": {
"minimum": 10,
"maximum": 20,
Expand Down Expand Up @@ -81,4 +99,4 @@
}
}
}
}
}
Loading
Loading