JSON Schemas
JsonSchemas
This algebra provides vocabulary to define JSON schemas of data types.
"org.julienrf" %% "endpoints-algebra-json-schema" % "0.15.0"
This module is dependency-free, it can be used independently of endpoints to define JSON schemas and interpret them as actual encoder, decoders or documentation.
The algebra introduces the concept of JsonSchema[A]
: a JSON schema for a type A
.
Basic types and record types
The trait provides some predefined JSON schemas (for String
, Int
, Boolean
, Seq
, etc.) and ways to combine them together to build more complex schemas.
For instance, given the following Rectangle
data type:
case class Rectangle(width: Double, height: Double)
We can represent instances of Rectangle
in JSON with a JSON object having properties corresponding to the case class fields. A JSON schema for such objects would be defined as follows:
implicit val rectangleSchema: JsonSchema[Rectangle] = (
field[Double]("width", Some("Rectangle width")) zip
field[Double]("height")
).xmap((Rectangle.apply _).tupled)(rect => (rect.width, rect.height))
The field
constructor defines a JSON object schema with one field of the given type and name (and an optional text documentation). A similar constructor, optField
, defines an optional field in a JSON object.
The return type of rectangleSchema
is declared to be JsonSchema[Rectangle]
, but we could have used a more specific type: Record[Rectangle]
. This subtype of JsonSchema[Rectangle]
provides additional operations such as zip
or tagged
(see the next section).
In the above example, we actually define two JSON object schemas (one for the width
field, of type Record[Double]
, and one for the height
field, of type Record[Double]
), and then we combine them into a single JSON object schema by using the zip
operation. Finally, we call the xmap
operation to turn the Record[(Double, Double)]
value returned by the zip
operation into a Record[Rectangle]
.
Sum types (sealed traits)
It is also possible to define schemas for sum types. Consider the following type definition, defining a Shape
, which can be either a Circle
or a Rectangle
:
sealed trait Shape
case class Circle(radius: Double) extends Shape
case class Rectangle(width: Double, height: Double) extends Shape
A possible JSON schema for this data type consists in using a JSON object with a discriminator field indicating whether the Shape
is a Rectangle
or a Circle
. Such a schema can be defined as follows:
// Given a `circleSchema: Record[Circle]` and a `rectangleSchema: Record[Rectangle]`
(
circleSchema.tagged("Circle") orElse
rectangleSchema.tagged("Rectangle")
).xmap[Shape] {
case Left(circle) => circle
case Right(rect) => rect
} {
case c: Circle => Left(c)
case r: Rectangle => Right(r)
}
(We have omitted the definition of circleSchema
for the sake of conciseness)
First, all the alternative record schemas (in this example, circeSchema
and rectangleSchema
) must be tagged
with a unique name. Then, the orElse
operation combines the alternative schemas into a single schema that accepts one of them.
The result of the tagged
operation is a Tagged[A]
schema. This subtype of JsonSchema[A]
models a schema that accepts one of several alternative schemas. It provides the orElse
operation.
The orElse
operation turns the Tagged[Circle]
and Tagged[Rectangle]
values into a Record[Either[Circle, Rectangle]]
, which is then, in this example, transformed into a Record[Shape]
by using xmap
.
By default, the discriminator field is named type
, but you can use another field name either by overriding the defaultDiscriminatorName
method of the algebra, or by wrapping the Tagged
schema in a withDiscriminator
call specifying the field name to use.
Refining schemas
The examples above show how to use xmap
to transform a JsonSchema[A]
into a JsonSchema[B]
. In case the transformation function from A
to B
can fail (for example, if it applies additional validation), you can use xmapPartial
instead of xmap
:
val evenNumberSchema: JsonSchema[Int] =
intJsonSchema.xmapPartial { n =>
if (n % 2 == 0) Valid(n)
else Invalid(s"Invalid even integer '$n'")
}(n => n)
In this example, we check that the decoded integer is even. If it is not, we return an error message.
Enumerations
There are different ways to represent enumerations in Scala:
scala.util.Enumeration
- Sealed trait with case objects
- Third-party libraries, e.g. Enumeratum
For example, an enumeration with three possible values can be defined as a sealed trait with three case objects:
sealed trait Status
case object Active extends Status
case object Inactive extends Status
case object Obsolete extends Status
The method stringEnumeration
in the JsonSchemas
algebra supports mapping the enum values to JSON strings. It has two parameters: the possible values, and a function to encode an enum value as a string.
implicit lazy val statusSchema: JsonSchema[Status] =
stringEnumeration[Status](Seq(Active, Inactive, Obsolete))(_.toString)
The resulting JsonSchema[Status]
allows defining JSON members with string values that are mapped to our case objects.
It will work similarly for other representations of enumerated values. Most of them provide values
which can conveniently be passed into stringEnumeration
. However, it is still possible to explicitly pass a certain subset of allowed values.
Tuples
JSON schemas for tuples from 2 to 22 elements are provided out of the box. For instance, if there are implicit JsonSchema
instances for types A
, B
, and C
, then you can summon a JsonSchema[(A, B, C)]
. Tuples are modeled in JSON with arrays, as recommended in the JSON Schema documentation.
Here is an example of JSON schema for a GeoJSON Point
, where GPS coordinates are modeled with a pair (longitude, latitude):
type Coordinates = (Double, Double) // (Longitude, Latitude)
case class Point(coordinates: Coordinates)
implicit val pointSchema: JsonSchema[Point] =
field[Coordinates]("coordinates")
.tagged("Point")
.xmap(Point(_))(_.coordinates)
Recursive types
You can reference a currently being defined schema without causing a StackOverflow
error by wrapping it in the lazyRecord
or lazyTagged
constructor:
case class Recursive(next: Option[Recursive])
val recursiveSchema: Record[Recursive] = (
optField("next")(lazyRecord(recursiveSchema, "Rec"))
).xmap(Recursive)(_.next)
Alternatives between schemas
You can define a schema as an alternative between other schemas with the operation orFallbackTo
:
val intOrBoolean: JsonSchema[Either[Int, Boolean]] =
intJsonSchema.orFallbackTo(booleanJsonSchema)
Because decoders derived from schemas defined with the operation orFallbackTo
literally “fallback” from one alternative to another, it makes it impossible to report good decoding failure messages. You should generally prefer using orElse
on “tagged” schemas.
Schemas documentation
Schema descriptions can include documentation information which is used by documentation interpreters such as the OpenAPI interpreter. We have already seen in the first section that object fields could be documented with a description. This section shows two other features related to schemas documentation.
You can give names to schemas. These names are used by the OpenAPI interpreter to group the schema definitions at one place, and then reference each schema by its name (see the Swagger “Components Section” documentation).
Use the named
method to give a name to a Record
, a Tagged
, or an Enum
schema.
You can also include examples of values for a schema (see the Swagger “Adding Examples” documentation). This is done by using the withExample
operation:
implicit val rectangleSchema: JsonSchema[Rectangle] = (
field[Double]("width", Some("Rectangle width")) zip
field[Double]("height")
).xmap(Rectangle.tupled)(rect => (rect.width, rect.height))
.withExample(Rectangle(10, 20))
Applying the OpenAPI interpreter to this schema definition produces the following JSON document:
{
"type": "object",
"properties": {
"width": {
"type": "number",
"format":"double",
"description": "Rectangle width"
},
"height":{
"type": "number",
"format": "double"
}
},
"required": ["width","height"],
"example": { "width": 10, "height": 20 }
}
The encoding of sealed traits in OpenAPI can be configured by overriding the coproductEncoding
method in the OpenAPI interpreter. By default, the OpenAPI interpreter will encode variants of sealed traits in the same way that they would be encoded if they were standalone records. However, it is sometimes useful to include in each variants’ schema a reference to the base type schema. The API documentation has more details.
Generic derivation of JSON schemas (based on Shapeless)
The module presented in this section uses Shapeless to generically derive JSON schemas for algebraic data type definitions (sealed traits and case classes).
"org.julienrf" %% "endpoints-json-schema-generic" % "0.15.0"
JSON schemas derivation
With this module, defining the JSON schema of the Shape
data type is reduced to the following:
implicit val shapeSchema: JsonSchema[Shape] = genericJsonSchema
The genericJsonSchema
operation builds a JSON schema for the given type. The rules for deriving the schema are the following:
- the schema of a case class is a JSON object,
- the schema of a sealed trait is the alternative of its leaf case class schemas, discriminated by the case class names,
- each case class field has a corresponding required JSON object property of the same name and type (for instance, the generic schema for the
Rectangle
type has awidth
required property of typeinteger
), - each case class field of type
Option[A]
for some typeA
has a corresponding optional JSON object property of the same name and type, - descriptions can be set for case class fields, case classes, or sealed traits by annotating these things with the
@docs
annotation, - for sealed traits, the discriminator field name can be defined by the
@discriminator
annotation, otherwise thedefaultDiscriminatorName
value is used, - the schema is named by the
@name
annotation, if present, or by invoking theclassTagToSchemaName
operation with theClassTag
of the type for which the schema is derived. If you wish to avoid naming the schema, use the@unnamed
annotation (unnamed schemas get inlined in their OpenAPI documentation). - the schema title is set with the
@title
annotation, if present
Here is an example that illustrates how to configure the generic schema derivation process:
@discriminator("kind")
@title("Geometric shape")
@name("ShapeSchema")
sealed trait Shape
@name("CircleSchema")
case class Circle(radius: Double) extends Shape
@name("RectangleSchema")
@docs("A quadrilateral with four right angles")
case class Rectangle(
@docs("Rectangle width") width: Double,
height: Double
)
In case you need to transform further a generically derived schema, you might want to use the genericRecord
or genericTagged
operations instead of genericJsonSchema
. These operations have a more specific return type than genericJsonSchema
: genericRecord
returns a Record
, and genericTagged
returns a Tagged
.
JSON schemas transformation
The module also takes advantage shapeless to provide a more convenient as
operation for transforming JSON schema definitions, instead of xmap
:
implicit val rectangleSchema: JsonSchema[Rectangle] = (
field[Double]("width") zip
field[Double]("height")
).as[Rectangle]
Generic derivation of JSON schemas (based on macros)
An alternative to the module presented in the preceding section is provided as a third-party module: endpoints-json-schemas-macros.
Please see the README of that project for more information on how to use it and its differences with the module provided by endpoints.