JSON Schemas
JsonSchemas
This algebra provides vocabulary to define JSON schemas of data types.
This module is dependency-free, it can be used independently of endpoints4s 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:
sourcecase 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:
sourceimplicit 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). Two other constructors, optField
and optFieldWithDefault
, define optional fields 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]
.
The preciseField
constructor defines a JSON object schema similar to optField
. preciseField
allows finer control by making a distinction between the property set with a null
value and the absence of the property.
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
:
sourcesealed 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:
source// 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, circleSchema
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 and adds a discriminator field to the schema.
The orElse
operation turns the Tagged[Circle]
and Tagged[Rectangle]
values into a Tagged[Either[Circle, Rectangle]]
, which is then, in this example, transformed into a Tagged[Shape]
by using xmap
.
By default, the discriminator field used to distinguish between tagged alternatives is named type
, but you can use another field name either by overriding the defaultDiscriminatorName
method of the algebra, or by calling the withDiscriminator
operation and specifying the field name to use.
Instead of using orElse
you can also make use of the orElseMerge
operation. This is similar to orElse
, but requires alternatives to share a parent. In the above example, this requirement is met since both Circle
and Rectangle
extend Shape
. The orElseMerge
operation turns the Tagged[Circle]
and Tagged[Rectangle]
values into a Tagged[Shape]
without any mapping. Note, however, that orElseMerge
uses ClassTag
under the hood, and thus requires both alternatives to have distinct types after erasure. Our example is valid because Rectangle
and Shape
are distinct classes, but consider a type Resource[A]
: then the types Resource[Rectangle]
and Resource[Circle]
have the same erased type (Resource[_]
), making them indistinguishable by the orElseMerge
operation. See also the documentation of isInstanceOf
.
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
:
sourceval 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:
sourcesealed 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.
sourceimplicit 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):
sourcetype Coordinates = (Double, Double) // (Longitude, Latitude)
case class Point(coordinates: Coordinates)
implicit val pointSchema: JsonSchema[Point] = (
field("type")(literal("Point")) zip
field[Coordinates]("coordinates")
).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:
sourcecase class Recursive(next: Option[Recursive])
val recursiveSchema: Record[Recursive] = lazyRecord("Rec")(
optField("next")(recursiveSchema)
).xmap(Recursive(_))(_.next)
Alternatives between schemas
You can define a schema as an alternative between other schemas with the operation orFallbackTo
:
sourceval 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 other features related to schemas documentation.
You can include a description and an example of value for a schema (see the Swagger “Adding Examples” documentation), with the operations withDescription
and withExample
, respectively:
sourceimplicit val rectangleSchema: JsonSchema[Rectangle] = (
field[Double]("width", Some("Rectangle width")) zip
field[Double]("height")
).xmap((Rectangle.apply _).tupled)(rect => (rect.width, rect.height))
.withExample(Rectangle(10, 20))
.withDescription("A rectangle shape")
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"],
"description": "A rectangle shape",
"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.
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.
Note that schema names must be valid URLs.
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).
JSON schemas derivation
With this module, defining the JSON schema of the Shape
data type is reduced to the following:
sourceimplicit 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, - each case class field with a default value has a corresponding optional JSON object property of the same name and type (decoders produce the default value when the property is missing),
- 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:
source@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
:
sourceimplicit val rectangleSchema: JsonSchema[Rectangle] = (
field[Double]("width") zip
field[Double]("height")
).as[Rectangle]
Mixing hand-written and derived schemas
The generic derivation mechanism for sealed traits derives a schema for each case class extending the trait. Sometimes, you want to use a custom schema for one of the case classes. You can achieve this by providing an implicit GenericRecord
instance for your case class:
sourcesealed trait Shape
case class Circle(radius: Double) extends Shape
case class Rectangle(width: Double, height: Double) extends Shape
implicit val shapeSchema: JsonSchema[Shape] = {
// For some reason, our JSON representation uses a `diameter`
// rather than a `radius`
val circleSchema: Record[Circle] =
field[Double]("diameter")
.xmap(diameter => Circle(diameter / 2))(circle => circle.radius * 2)
implicit val circleGenericRecord: GenericJsonSchema.GenericRecord[Circle] =
new GenericJsonSchema.GenericRecord(circleSchema)
// The generic schema for `Shape` will synthesize the schema for `Rectangle`,
// but it will use the implicitly provided `GenericRecord[Circle]` for `Circle.
genericJsonSchema[Shape]
}
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 endpoints4s.