Skip to content

Commit 73077e7

Browse files
authored
Merge pull request #207 from ghik/issue/206
Not dropping too many null values in OpenAPI encoders
2 parents b42865c + a5d1905 commit 73077e7

File tree

13 files changed

+146
-92
lines changed

13 files changed

+146
-92
lines changed

Diff for: apispec-model/src/main/scala/sttp/apispec/Schema.scala

+6-1
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,13 @@ case class Schema(
132132
*/
133133
def nullable: Schema = `type` match {
134134
case Some(types) =>
135+
val NullExample = ExampleSingleValue("null")
135136
if (types.contains(SchemaType.Null)) this // ensure idempotency
136-
else copy(`type` = Some(types :+ SchemaType.Null))
137+
else copy(
138+
`type` = Some(types :+ SchemaType.Null),
139+
`enum` = `enum`.orElse(`const`.map(List(_))).map(vs => (vs :+ NullExample).distinct),
140+
`const` = None
141+
)
137142

138143
case None =>
139144
// Representing nullable schemas (without explicit `type`) using `anyOf` is safer than `oneOf`.

Diff for: apispec-model/src/test/scala/sttp/apispec/SchemaTest.scala

+18
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,22 @@ class SchemaTest extends AnyFunSuite {
2020
assert(schema.nullable == Schema(anyOf = List(Schema(SchemaType.String), Schema(SchemaType.Number), Schema.Null)))
2121
assert(schema.nullable.nullable == schema.nullable) // idempotency
2222
}
23+
24+
test("nullable enum") {
25+
val schema = Schema(`type` = Some(List(SchemaType.String)), `enum` = Some(List("a", "b").map(ExampleSingleValue(_))))
26+
assert(schema.nullable == Schema(
27+
`type` = Some(List(SchemaType.String, SchemaType.Null)),
28+
`enum` = Some(List("a", "b", "null").map(ExampleSingleValue(_)))
29+
))
30+
assert(schema.nullable.nullable == schema.nullable) // idempotency
31+
}
32+
33+
test("nullable const") {
34+
val schema = Schema(`type` = Some(List(SchemaType.String)), `const` = Some(ExampleSingleValue("a")))
35+
assert(schema.nullable == Schema(
36+
`type` = Some(List(SchemaType.String, SchemaType.Null)),
37+
`enum` = Some(List("a", "null").map(ExampleSingleValue(_)))
38+
))
39+
assert(schema.nullable.nullable == schema.nullable)
40+
}
2341
}

Diff for: apispec-model/src/test/scala/sttp/apispec/validation/SchemaComparatorTest.scala

+3-3
Original file line numberDiff line numberDiff line change
@@ -217,11 +217,11 @@ abstract class SchemaComparatorTest(referencePrefix: String) extends AnyFunSuite
217217
}
218218

219219
private def enums(values: Any*): List[ExampleSingleValue] =
220-
values.toList.map(ExampleSingleValue)
220+
values.toList.map(ExampleSingleValue(_))
221221

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

227227
test("compatible enum & const") {

Diff for: jsonschema-circe/src/main/scala/sttp/apispec/internal/JsonSchemaCirceEncoders.scala

+24-6
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,19 @@ import io.circe._
55
import io.circe.generic.semiauto.deriveEncoder
66
import io.circe.parser.parse
77
import io.circe.syntax._
8+
import sttp.apispec.internal.JsonSchemaCirceEncoders.ObjectEncoderOps
89

910
import scala.collection.immutable.ListMap
11+
import scala.language.implicitConversions
1012

1113
trait JsonSchemaCirceEncoders {
1214
def anyObjectEncoding: AnySchema.Encoding
1315

1416
def openApi30: Boolean = false
1517

18+
protected final implicit def objectEncoderOps[T](encoder: Encoder.AsObject[T]): ObjectEncoderOps[T] =
19+
new ObjectEncoderOps(encoder)
20+
1621
implicit lazy val encoderSchema: Encoder[Schema] = Encoder.AsObject
1722
.instance { (s: Schema) =>
1823
val nullSchema = Schema(`type` = Some(List(SchemaType.Null)))
@@ -115,7 +120,7 @@ trait JsonSchemaCirceEncoders {
115120
)
116121
)
117122
}
118-
.mapJsonObject(expandExtensions)
123+
.dropNullsExpandExtensions
119124

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

155160
implicit val encoderDiscriminator: Encoder[Discriminator] =
156-
deriveEncoder[Discriminator]
161+
deriveEncoder[Discriminator].dropNulls
157162

158163
implicit val encoderExternalDocumentation: Encoder[ExternalDocumentation] =
159-
deriveEncoder[ExternalDocumentation].mapJsonObject(expandExtensions)
164+
deriveEncoder[ExternalDocumentation].dropNullsExpandExtensions
160165

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

199+
// just for backward compatibility
200+
private[apispec] def expandExtensions(jsonObject: JsonObject): JsonObject =
201+
JsonSchemaCirceEncoders.expandExtensions(jsonObject)
202+
203+
}
204+
object JsonSchemaCirceEncoders {
205+
class ObjectEncoderOps[T](private val encoder: Encoder.AsObject[T]) extends AnyVal {
206+
def dropNulls: Encoder.AsObject[T] =
207+
encoder.mapJsonObject(_.filter { case (_, v) => !v.isNull })
208+
209+
def dropNullsExpandExtensions: Encoder.AsObject[T] =
210+
dropNulls.mapJsonObject(expandExtensions)
211+
}
212+
194213
/*
195214
Openapi extensions are arbitrary key-value data that could be added to some of models in specifications, such
196215
as `OpenAPI` itself, `License`, `Parameter`, etc.
@@ -221,7 +240,7 @@ trait JsonSchemaCirceEncoders {
221240
x-foo: 42
222241
```
223242
*/
224-
private[apispec] def expandExtensions(jsonObject: JsonObject): JsonObject = {
243+
private def expandExtensions(jsonObject: JsonObject): JsonObject = {
225244
val jsonWithoutExt = jsonObject.filterKeys(_ != "extensions")
226245
jsonObject("extensions")
227246
.flatMap(_.asObject)
@@ -230,11 +249,10 @@ trait JsonSchemaCirceEncoders {
230249
allKeys.foldLeft(JsonObject.empty) { case (acc, key) =>
231250
extObject(key).orElse(jsonWithoutExt(key)) match {
232251
case Some(value) => acc.add(key, value)
233-
case None => acc
252+
case None => acc
234253
}
235254
}
236255
}
237256
.getOrElse(jsonWithoutExt)
238257
}
239-
240258
}

Diff for: openapi-circe/src/main/scala/sttp/apispec/openapi/internal/InternalSttpOpenAPICirceEncoders.scala

+21-22
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import sttp.apispec.internal.JsonSchemaCirceEncoders
1010
import scala.collection.immutable.ListMap
1111

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

30-
deriveEncoder[OAuthFlow].mapJsonObject(expandExtensions)
30+
deriveEncoder[OAuthFlow].dropNullsExpandExtensions
3131
}
32-
implicit val encoderOAuthFlows: Encoder[OAuthFlows] = deriveEncoder[OAuthFlows].mapJsonObject(expandExtensions)
32+
implicit val encoderOAuthFlows: Encoder[OAuthFlows] = deriveEncoder[OAuthFlows].dropNullsExpandExtensions
3333
implicit val encoderSecurityScheme: Encoder[SecurityScheme] =
34-
deriveEncoder[SecurityScheme].mapJsonObject(expandExtensions)
34+
deriveEncoder[SecurityScheme].dropNullsExpandExtensions
3535

3636
implicit val encoderHeader: Encoder[Header] = deriveEncoder[Header]
37-
implicit val encoderExample: Encoder[Example] = deriveEncoder[Example].mapJsonObject(expandExtensions)
38-
implicit val encoderResponse: Encoder[Response] = deriveEncoder[Response].mapJsonObject(expandExtensions)
39-
implicit val encoderLink: Encoder[Link] = deriveEncoder[Link].mapJsonObject(expandExtensions)
37+
implicit val encoderExample: Encoder[Example] = deriveEncoder[Example].dropNullsExpandExtensions
38+
implicit val encoderResponse: Encoder[Response] = deriveEncoder[Response].dropNullsExpandExtensions
39+
implicit val encoderLink: Encoder[Link] = deriveEncoder[Link].dropNullsExpandExtensions
4040
implicit val encoderCallback: Encoder[Callback] = Encoder.instance { callback =>
4141
Json.obj(callback.pathItems.map { case (path, pathItem) => path -> pathItem.asJson }.toList: _*)
4242
}
43-
implicit val encoderEncoding: Encoder[Encoding] = deriveEncoder[Encoding].mapJsonObject(expandExtensions)
44-
implicit val encoderMediaType: Encoder[MediaType] = deriveEncoder[MediaType].mapJsonObject(expandExtensions)
45-
implicit val encoderRequestBody: Encoder[RequestBody] = deriveEncoder[RequestBody].mapJsonObject(expandExtensions)
43+
implicit val encoderEncoding: Encoder[Encoding] = deriveEncoder[Encoding].dropNullsExpandExtensions
44+
implicit val encoderMediaType: Encoder[MediaType] = deriveEncoder[MediaType].dropNullsExpandExtensions
45+
implicit val encoderRequestBody: Encoder[RequestBody] = deriveEncoder[RequestBody].dropNullsExpandExtensions
4646
implicit val encoderParameterStyle: Encoder[ParameterStyle] = { e => Encoder.encodeString(e.value) }
4747
implicit val encoderParameterIn: Encoder[ParameterIn] = { e => Encoder.encodeString(e.value) }
48-
implicit val encoderParameter: Encoder[Parameter] = deriveEncoder[Parameter].mapJsonObject(expandExtensions)
48+
implicit val encoderParameter: Encoder[Parameter] = deriveEncoder[Parameter].dropNullsExpandExtensions
4949
implicit val encoderResponseMap: Encoder[ListMap[ResponsesKey, ReferenceOr[Response]]] =
5050
(responses: ListMap[ResponsesKey, ReferenceOr[Response]]) => {
5151
val fields = responses.map {
@@ -70,22 +70,21 @@ trait InternalSttpOpenAPICirceEncoders extends JsonSchemaCirceEncoders {
7070
implicit def encodeListMapForCallbacks: Encoder[ListMap[String, ReferenceOr[Callback]]] =
7171
doEncodeListMap(nullWhenEmpty = true)
7272

73-
deriveEncoder[Operation].mapJsonObject(expandExtensions)
73+
deriveEncoder[Operation].dropNullsExpandExtensions
7474
}
75-
implicit val encoderPathItem: Encoder[PathItem] = deriveEncoder[PathItem].mapJsonObject(expandExtensions)
75+
implicit val encoderPathItem: Encoder[PathItem] = deriveEncoder[PathItem].dropNullsExpandExtensions
7676
implicit val encoderPaths: Encoder[Paths] = Encoder.instance { paths =>
7777
val extensions = paths.extensions.asJsonObject
7878
val pathItems = paths.pathItems.asJson
7979
pathItems.asObject.map(_.deepMerge(extensions).asJson).getOrElse(pathItems)
8080
}
81-
implicit val encoderComponents: Encoder[Components] = deriveEncoder[Components].mapJsonObject(expandExtensions)
81+
implicit val encoderComponents: Encoder[Components] = deriveEncoder[Components].dropNullsExpandExtensions
8282
implicit val encoderServerVariable: Encoder[ServerVariable] =
83-
deriveEncoder[ServerVariable].mapJsonObject(expandExtensions)
84-
implicit val encoderServer: Encoder[Server] = deriveEncoder[Server].mapJsonObject(expandExtensions)
85-
implicit val encoderTag: Encoder[Tag] = deriveEncoder[Tag].mapJsonObject(expandExtensions)
86-
implicit val encoderInfo: Encoder[Info] = deriveEncoder[Info].mapJsonObject(expandExtensions)
87-
implicit val encoderContact: Encoder[Contact] = deriveEncoder[Contact].mapJsonObject(expandExtensions)
88-
implicit val encoderLicense: Encoder[License] = deriveEncoder[License].mapJsonObject(expandExtensions)
89-
implicit val encoderOpenAPI: Encoder[OpenAPI] =
90-
deriveEncoder[OpenAPI].mapJsonObject(expandExtensions).mapJson(_.deepDropNullValues)
83+
deriveEncoder[ServerVariable].dropNullsExpandExtensions
84+
implicit val encoderServer: Encoder[Server] = deriveEncoder[Server].dropNullsExpandExtensions
85+
implicit val encoderTag: Encoder[Tag] = deriveEncoder[Tag].dropNullsExpandExtensions
86+
implicit val encoderInfo: Encoder[Info] = deriveEncoder[Info].dropNullsExpandExtensions
87+
implicit val encoderContact: Encoder[Contact] = deriveEncoder[Contact].dropNullsExpandExtensions
88+
implicit val encoderLicense: Encoder[License] = deriveEncoder[License].dropNullsExpandExtensions
89+
implicit val encoderOpenAPI: Encoder[OpenAPI] = deriveEncoder[OpenAPI].dropNullsExpandExtensions
9190
}

Diff for: openapi-circe/src/test/resources/securitySchema/security-schema-with-empty-scopes.json

-22
This file was deleted.

Diff for: openapi-circe/src/test/resources/securitySchema/security-schema-with-scopes.json

-22
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"type" : "oauth2",
3+
"flows" : {
4+
"clientCredentials" : {
5+
"tokenUrl" : "openapi-circe-token",
6+
"scopes" : {
7+
8+
}
9+
}
10+
}
11+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"type" : "oauth2",
3+
"flows" : {
4+
"clientCredentials" : {
5+
"tokenUrl" : "openapi-circe-token",
6+
"scopes" : {
7+
"example" : "description"
8+
}
9+
}
10+
}
11+
}

Diff for: openapi-circe/src/test/resources/spec/3.0/schema.json

+17-1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@
1515
"nullable": true,
1616
"description": "nullable string"
1717
},
18+
"nullable enum" : {
19+
"description" : "nullable enum",
20+
"enum" : [
21+
"a",
22+
"b",
23+
null
24+
]
25+
},
1826
"nullable reference": {
1927
"nullable": true,
2028
"allOf": [
@@ -37,6 +45,14 @@
3745
"ex1"
3846
]
3947
},
48+
"object with example" : {
49+
"description" : "object with example",
50+
"example" : {
51+
"a" : 1,
52+
"b" : null
53+
},
54+
"type" : "object"
55+
},
4056
"min/max": {
4157
"minimum": 10,
4258
"maximum": 20,
@@ -67,4 +83,4 @@
6783
}
6884
}
6985
}
70-
}
86+
}

Diff for: openapi-circe/src/test/resources/spec/3.1/schema.json

+19-1
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@
1717
],
1818
"description": "nullable string"
1919
},
20+
"nullable enum" : {
21+
"description" : "nullable enum",
22+
"enum" : [
23+
"a",
24+
"b",
25+
null
26+
]
27+
},
2028
"nullable reference": {
2129
"anyOf": [
2230
{
@@ -45,6 +53,16 @@
4553
]
4654
]
4755
},
56+
"object with example" : {
57+
"description" : "object with example",
58+
"examples" : [
59+
{
60+
"a" : 1,
61+
"b" : null
62+
}
63+
],
64+
"type" : "object"
65+
},
4866
"min/max": {
4967
"minimum": 10,
5068
"maximum": 20,
@@ -81,4 +99,4 @@
8199
}
82100
}
83101
}
84-
}
102+
}

0 commit comments

Comments
 (0)