An enhancement to springdoc-openapi that adds better support for Scala.
Scala isn't well-supported in springdoc-openapi by default, for example:
case class
parameters are not recognized by default, one has to add something like@BeanProperty
to each- even with
@BeanProperty
, most parameters with generic type (likeOption
) don't work correctly - even with
@BeanProperty
, all parameters are marked as not required in generated OpenAPI docs - Spring endpoints returning
Unit
are not "No Content" but instead show that the endpoint returnsBoxedUnit
One option to overcome these limitations is to annotate the model with annotations provided by springdoc-openapi.
But even with them, @BeanPropery
or equivalent must be added to each case class parameter.
This library aims to avoid pollution of the model by custom annotations and dependency on Spring related libraries.
- all parameters of a
case class
are automatically recognized without any custom annotations - all parameters of a
case class
that have a type different fromOption
are marked as required - Spring endpoints returning Unit are "No Content"
- support for basic Scala collections (
Map
,Seq
,Set
,Array
) as types ofcase class
parameters - only top-level case classes need to be registered, child case classes are then recursively registered
- support for Scala
Enumeration
where simpleValue
constructor is used (withoutname
) - support for sum ADTs (
sealed trait
andsealed abstract class
) with optional discriminator
springdoc-openapi-scala supports two major versions of springdoc-openapi: 1.x and 2.x.
The library has springdoc-openapi as a provided dependency, thus users of the library have to include that dependency in their projects:
- for springdoc-openapi 1.x versions
1.6.7
up to1.7.0
(including) of"org.springdoc" % "springdoc-openapi-webmvc-core"
are supported - for springdoc-openapi 2.x versions
2.0.0
up to2.3.0
(including) of"org.springdoc" % "springdoc-openapi-starter-webmvc-api"
are supported
If you want to use the library with springdoc-openapi 1.x, add:
libraryDependencies ++= Seq("za.co.absa" %% "springdoc-openapi-scala-1" % VERSION)
if with springdoc-openapi 2.x, add:
libraryDependencies ++= Seq("za.co.absa" %% "springdoc-openapi-scala-2" % VERSION)
If you want to use the library with springdoc-openapi 1.x, add:
<dependency>
<groupId>za.co.absa</groupId>
<artifactId>springdoc-openapi-scala-1_${scala_binary_version}</artifactId>
<version>${version}</version>
</dependency>
if with springdoc-openapi 2.x, add:
<dependency>
<groupId>za.co.absa</groupId>
<artifactId>springdoc-openapi-scala-2_${scala_binary_version}</artifactId>
<version>${version}</version>
</dependency>
where scala_binary_version
is either 2.12
or 2.13
.
For springdoc-openapi 1.x
import io.swagger.v3.oas.models.OpenAPI
import io.swagger.v3.oas.models.info.Info
import org.springdoc.core.customizers.OpenApiCustomiser
import org.springframework.context.annotation.{Bean, Configuration}
import za.co.absa.springdocopenapiscala.{Bundle, OpenAPIModelRegistration}
@Configuration
class OpenAPIConfiguration {
private val springDocOpenAPIScalaBundle = new Bundle(
Seq((openAPI: OpenAPI) =>
openAPI.setInfo(
new Info()
.title("Example API with springdoc-openapi v1.x")
.version("1.0.0")
)
)
)
@Bean
def openAPICustomizer: OpenApiCustomiser = springDocOpenAPIScalaBundle.customizer
@Bean
def openAPIModelRegistration: OpenAPIModelRegistration = springDocOpenAPIScalaBundle.modelRegistration
}
For springdoc-openapi 2.x
import io.swagger.v3.oas.models.OpenAPI
import io.swagger.v3.oas.models.info.Info
import org.springdoc.core.customizers.OpenApiCustomizer
import org.springframework.context.annotation.{Bean, Configuration}
import za.co.absa.springdocopenapiscala.{Bundle, OpenAPIModelRegistration}
@Configuration
class OpenAPIConfiguration {
private val springDocOpenAPIScalaBundle = new Bundle(
Seq((openAPI: OpenAPI) =>
openAPI.setInfo(
new Info()
.title("Example API with springdoc-openapi v2.x")
.version("1.0.0")
)
)
)
@Bean
def openAPICustomizer: OpenApiCustomizer = springDocOpenAPIScalaBundle.customizer
@Bean
def openAPIModelRegistration: OpenAPIModelRegistration = springDocOpenAPIScalaBundle.modelRegistration
}
Example model:
case class ExampleModelRequest(a: Int, b: String, c: Option[Int])
case class ExampleModelResponse(d: Seq[Int], e: Boolean)
can be registered for example in Controller
:
@RestController
@RequestMapping(
value = Array("/api/v1/example")
)
class ExampleController @Autowired()(openAPIModelRegistration: OpenAPIModelRegistration) {
openAPIModelRegistration.register[ExampleModelRequest]()
openAPIModelRegistration.register[ExampleModelResponse]()
@PostMapping(
value = Array("/some-endpoint"),
produces = Array(MediaType.APPLICATION_JSON_VALUE)
)
def someEndpoint(@RequestBody body: ExampleModelRequest): CompletableFuture[ExampleModelResponse] = {
...
}
}
To add support for custom types (or overwrite handling of any type supported by the library)
one can create a custom ExtraTypesHandler
and provide it when creating a Bundle
.
There are multiple ways to do so, the simplest is to use ExtraTypesHandling.simpleMapping
, for example:
OpenAPIModelRegistration.ExtraTypesHandling.simpleMapping {
case t if t =:= typeOf[JsValue] =>
val schema = new Schema
schema.setType("string")
schema.setFormat("json")
schema
}
This ExtraTypesHandler
handles JsValue
by mapping it to simple OpenAPI string
type with json
format.
But ExtraTypesHandler
can also be much more powerful, for example:
case class CustomClassComplexChild(a: Option[Int])
class CustomClass(val complexChild: CustomClassComplexChild) {
// these won't be included
val meaningOfLife: Int = 42
val alphabetHead: String = "abc"
}
...
val extraTypesHandler: ExtraTypesHandler = (tpe: Type) =>
tpe match {
case t if t =:= typeOf[CustomClass] =>
val childTypesToBeResolvedByTheLibrary = Set(typeOf[CustomClassComplexChild])
val handleFn: HandleFn = (resolvedChildTypes, context) => {
val name = "CustomClass"
val customClassComplexChildResolvedSchema = resolvedChildTypes(typeOf[CustomClassComplexChild])
val schema = new Schema
schema.addProperty("complexChild", customClassComplexChildResolvedSchema)
context.components.addSchemas(name, schema)
val schemaReference = new Schema
schemaReference.set$ref(s"#/components/schemas/$name")
schemaReference
}
(childTypesToBeResolvedByTheLibrary, handleFn)
}
This ExtraTypesHandler
handles CustomClass
.
CustomClass
uses CustomClassComplexChild
,
so the handler requests the library to resolve its type (childTypesToBeResolvedByTheLibrary
).
This resolved type is available as input to HandleFn
.
Then, in handleFn
, the handler creates a Schema
object for CustomClass
,
adds it to Components
so that it can be referenced by name CustomClass
,
and returns reference to that object.
It is possible to further customize registration by providing custom RegistrationConfig
to OpenAPIModelRegistration
.
val components = ...
val registration = OpenAPIModelRegistration(
components,
config = RegistrationConfig(
OpenAPIModelRegistration.RegistrationConfig(
sumADTsShape =
// default values apply for discriminatorPropertyNameFn, addDiscriminatorPropertyOnlyToDirectChildren
OpenAPIModelRegistration.RegistrationConfig.SumADTsShape.WithDiscriminator()
)
)
)
This config property sets how sum ADTs are registered. It has two possible values:
RegistrationConfig.SumADTsShape.WithoutDiscriminator
- default option, doesn't add discriminatorsRegistrationConfig.SumADTsShape.WithDiscriminator(discriminatorPropertyNameFn, addDiscriminatorPropertyOnlyToDirectChildren)
- adds discriminator to sealed types schema, and also adds discriminator to sum ADTs elements properties; discriminator property name is customizable bydiscriminatorPropertyNameFn
, by default it takes sealed type name, converts its first letter to lower case, and adds"Type"
suffix, for example if sealed type name isExpression
, the property name isexpressionType
; ifaddDiscriminatorPropertyOnlyToDirectChildren
isfalse
, discriminator property is added to all children, so for example inADT = A | B | C; B = D | E
discriminator ofADT
would be added toA
,C
,D
,E
(D
andE
would have discriminator ofB
in addition to that) while withaddDiscriminatorPropertyOnlyToDirectChildren
set totrue
(default) it would be added only toA
andC
Can be found in this repo: link. It generates the following OpenAPI JSON doc:
{
"openapi": "3.0.1",
"info": {
"title": "Example API with springdoc-openapi v1.x",
"version": "1.0.0"
},
"servers": [
{
"url": "http://localhost:8080",
"description": "Generated server url"
}
],
"paths": {
"/api/v1/example/some-endpoint": {
"post": {
"tags": [
"example-controller"
],
"operationId": "someEndpoint",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ExampleModelRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ExampleModelResponse"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"IntLiteral": {
"required": [
"value"
],
"properties": {
"value": {
"type": "string"
}
}
},
"StringLiteral": {
"required": [
"value"
],
"properties": {
"value": {
"type": "string"
}
}
},
"Literal": {
"oneOf": [
{
"$ref": "#/components/schemas/IntLiteral"
},
{
"$ref": "#/components/schemas/StringLiteral"
}
]
},
"Something": {},
"Expression": {
"oneOf": [
{
"$ref": "#/components/schemas/Literal"
},
{
"$ref": "#/components/schemas/Something"
}
]
},
"ExampleModelRequest": {
"required": [
"a",
"b",
"d",
"e"
],
"properties": {
"a": {
"type": "integer",
"format": "int32"
},
"b": {
"type": "string"
},
"c": {
"type": "integer",
"format": "int32"
},
"d": {
"type": "string",
"format": "json"
},
"e": {
"$ref": "#/components/schemas/Expression"
},
"f": {
"$ref": "#/components/schemas/Expression"
}
}
},
"ExampleModelResponse": {
"required": [
"d",
"e"
],
"properties": {
"d": {
"type": "array",
"items": {
"type": "integer",
"format": "int32"
}
},
"e": {
"type": "boolean"
}
}
}
}
}
}
Can be found in this repo: link. It generates the following OpenAPI JSON doc:
{
"openapi": "3.0.1",
"info": {
"title": "Example API with springdoc-openapi v2.x",
"version": "1.0.0"
},
"servers": [
{
"url": "http://localhost:8080",
"description": "Generated server url"
}
],
"paths": {
"/api/v1/example/some-endpoint": {
"post": {
"tags": [
"example-controller"
],
"operationId": "someEndpoint",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ExampleModelRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ExampleModelResponse"
}
}
}
}
}
}
},
"/api/v1/example/empty-endpoint": {
"post": {
"tags": [
"example-controller"
],
"operationId": "emptyEndpoint",
"responses": {
"204": {
"description": "No Content"
}
}
}
}
},
"components": {
"schemas": {
"ExampleModelRequest": {
"required": [
"a",
"b",
"d",
"e"
],
"properties": {
"a": {
"type": "integer",
"format": "int32"
},
"b": {
"type": "string"
},
"c": {
"type": "integer",
"format": "int32"
},
"d": {
"type": "string",
"enum": [
"OptionC",
"OptionB",
"OptionA"
]
},
"e": {
"type": "string",
"format": "json"
}
}
},
"ExampleModelResponse": {
"required": [
"d",
"e"
],
"properties": {
"d": {
"type": "array",
"items": {
"type": "integer",
"format": "int32"
}
},
"e": {
"type": "boolean"
}
}
}
}
}
}