
endpoints4s.algebra.JsonSchemas.scala Maven / Gradle / Ivy
The newest version!
package endpoints4s.algebra
import java.util.UUID
import endpoints4s.{PartialInvariantFunctor, PartialInvariantFunctorSyntax, Tupler, Validated}
import scala.collection.compat._
import scala.reflect.ClassTag
import scala.util.control.Exception
/**
* An algebra interface for describing algebraic data types. Such descriptions
* can be interpreted to produce a JSON schema of the data type, a JSON encoder,
* a JSON decoder, etc.
*
* A description contains the fields of a case class and their type, and the
* constructor names of a sealed trait.
*
* For instance, consider the following record type:
*
* {{{
* case class User(name: String, age: Int)
* }}}
*
* Its description is the following:
*
* {{{
* object User {
* implicit val schema: JsonSchema[User] = (
* field[String]("name") zip
* field[Int]("age")
* ).xmap((User.apply _).tupled)(Function.unlift(User.unapply))
* }
* }}}
*
* The description says that the record type has two fields, the first one has type `String` and is
* named “name”, and the second one has type `Int` and name “age”.
*
* To describe sum types you have to explicitly “tag” each alternative:
*
* {{{
* sealed trait Shape
* case class Circle(radius: Double) extends Shape
* case class Rectangle(width: Double, height: Double) extends Shape
*
* object Shape {
* implicit val schema: JsonSchema[Shape] = {
* val circleSchema = field[Double]("radius").xmap(Circle)(Function.unlift(Circle.unapply))
* val rectangleSchema = (
* field[Double]("width") zip
* field[Double]("height")
* ).xmap((Rectangle.apply _).tupled)(Function.unlift(Rectangle.unapply))
* (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)
* }
* }
* }
* }}}
*
* @group algebras
* @groupname types Types
* @groupdesc types Types introduced by the algebra
* @groupprio types 1
* @groupname operations Operations
* @groupdesc operations Operations creating and transforming values
* @groupprio operations 2
*/
trait JsonSchemas extends TuplesSchemas with PartialInvariantFunctorSyntax {
/**
* The JSON schema of a type `A`
*
* JSON schemas can be interpreted as encoders serializing values of type `A` into JSON,
* decoders de-serializing JSON documents into values of type `A`, or documentation rendering
* the underlying JSON schema.
*
* The `JsonSchemas` trait provides implicit definitions of `JsonSchema[A]` for basic types (`Int`,
* `Double`, `String`, etc.), and operations such as [[field]], [[optField]], or [[enumeration]],
* which construct more complex JSON schemas.
*
* @note This type has implicit methods provided by the [[PartialInvariantFunctorSyntax]],
* [[InvariantFunctorSyntax]], and [[JsonSchemaOps]] classes.
* @group types
*/
type JsonSchema[A]
/**
* Provides `xmap` and `xmapPartial` operations.
*
* @see [[PartialInvariantFunctorSyntax]] and [[InvariantFunctorSyntax]]
*/
implicit def jsonSchemaPartialInvFunctor: PartialInvariantFunctor[JsonSchema]
/**
* A more specific type of JSON schema for record types (case classes)
*
* Values of type `Record[A]` can be constructed with the operations [[field]] and [[optField]].
*
* @note This type has implicit methods provided by the [[PartialInvariantFunctorSyntax]],
* [[InvariantFunctorSyntax]], and [[RecordOps]] classes.
* @group types
*/
type Record[A] <: JsonSchema[A]
/**
* Provides `xmap` and `xmapPartial` operations.
*
* @see [[PartialInvariantFunctorSyntax]] and [[InvariantFunctorSyntax]]
*/
implicit def recordPartialInvFunctor: PartialInvariantFunctor[Record]
/**
* A more specific type of JSON schema for sum types (sealed traits)
*
* “Tagged” schemas include the name of the type `A` as an additional discriminator field. By default,
* the name of the discriminator field is defined by the operation [[defaultDiscriminatorName]] but
* it can be customized by calling the operation `withDiscriminator`.
*
* Values of type `Tagged[A]` can be constructed by calling the operation `tagged` on a `Record[A]`.
*
* @note This type has implicit methods provided by the [[PartialInvariantFunctorSyntax]],
* [[InvariantFunctorSyntax]], and [[TaggedOps]] classes.
* @group types
*/
type Tagged[A] <: JsonSchema[A]
/**
* Provides `xmap` and `xmapPartial` operations.
*
* @see [[PartialInvariantFunctorSyntax]] and [[InvariantFunctorSyntax]]
*/
implicit def taggedPartialInvFunctor: PartialInvariantFunctor[Tagged]
/**
* A more specific type of JSON schema for enumerations, i.e. types that have a specific set of valid values
*
* Values of type `Enum[A]` can be constructed by the operations [[enumeration]] and [[stringEnumeration]].
*
* @note This type has implicit methods provided by the [[EnumOps]] class.
* @group types
*/
type Enum[A] <: JsonSchema[A]
/** Promotes a schema to an enumeration.
*
* - Decoder interpreters fail if the input value does not match the encoded
* values of any of the possible values,
* - Encoder interpreters never fail, even if the value is not contained in
* the set of possible values,
* - Documentation interpreters enrich the JSON schema with an `enum` property
* listing the possible values.
*
* @group operations
*/
def enumeration[A](values: Seq[A])(tpe: JsonSchema[A]): Enum[A]
/**
* Convenient constructor for enumerations represented by string values.
* @group operations
*/
final def stringEnumeration[A](
values: Seq[A]
)(encode: A => String)(implicit tpe: JsonSchema[String]): Enum[A] = {
val encoded = values.map(a => (a, encode(a))).toMap
val decoded = encoded.map(_.swap)
assert(
encoded.size == decoded.size,
"Enumeration values must have different string representation"
)
enumeration(values)(
tpe.xmapPartial { str =>
Validated.fromOption(decoded.get(str))(
s"Invalid value: ${str} ; valid values are: ${values.map(encode).mkString(", ")}"
)
}(encode)
)
}
/**
* A schema for a statically known value.
*
* - Decoder interpreters first try to decode incoming values with the given `tpe` schema,
* and then check that it is equal to the given `value`,
* - Encoder interpreters always produce the given `value`, encoded according to `tpe`,
* - Documentation interpreters enrich the JSON schema with a `const` property documenting
* its only possible value (or an `enum` property with a single item).
*
* This is useful to model schemas of objects containing extra fields that
* are absent from their Scala representation. For example, here is a schema
* for a GeoJSON point:
*
* {{{
* case class Point(lon: Double, lat: Double)
* val pointSchema = (
* field("type")(literal("Point")) zip
* field[(Double, Double)]("coordinates")
* ).xmap(Point.tupled)(p => (p.lon, p.lat))
* }}}
*
* @group operations
*/
final def literal[A](
value: A
)(implicit tpe: JsonSchema[A]): JsonSchema[Unit] =
(enumeration(value :: Nil)(tpe): JsonSchema[A]).xmap(_ => ())(_ => value)
/** Annotates the record JSON schema with a name */
def namedRecord[A](schema: Record[A], name: String): Record[A]
/** Annotates the tagged JSON schema with a name */
def namedTagged[A](schema: Tagged[A], name: String): Tagged[A]
/** Annotates the enumeration JSON schema with a name */
def namedEnum[A](schema: Enum[A], name: String): Enum[A]
/**
* Captures a lazy reference to a JSON schema currently being defined:
*
* {{{
* case class Recursive(next: Option[Recursive])
* val recursiveSchema: Record[Recursive] = (
* optField("next")(lazyRecord(recursiveSchema, "Rec"))
* ).xmap(Recursive)(_.next)
* }}}
*
* Interpreters should return a JsonSchema value that does not evaluate
* the given `schema` unless it is effectively used.
*
* @param schema The record JSON schema whose evaluation should be delayed
* @param name A unique name identifying the schema
* @group operations
*/
def lazyRecord[A](schema: => Record[A], name: String): JsonSchema[A]
/**
* Captures a lazy reference to a JSON schema currently being defined.
*
* Interpreters should return a JsonSchema value that does not evaluate
* the given `schema` unless it is effectively used.
*
* @param schema The tagged JSON schema whose evaluation should be delayed
* @param name A unique name identifying the schema
* @group operations
*/
def lazyTagged[A](schema: => Tagged[A], name: String): JsonSchema[A]
/** The JSON schema of a record with no fields
*
* - Encoder interpreters produce an empty JSON object,
* - Decoder interpreters fail if the JSON value is not a JSON object,
* - Documentation interpreters produce the JSON schema of a JSON object schema with no properties.
*
* @group operations
*/
implicit def emptyRecord: Record[Unit]
/** The JSON schema of a record with a single field `name` of type `A`
*
* - Encoder interpreters produce a JSON object with one property of the given `name`,
* - Decoder interpreters fail if the JSON value is not a JSON object, or if it
* doesn’t contain the `name` property, or if the property has an
* invalid value (according to its `tpe`),
* - Documentation interpreters produce the JSON schema of a JSON object schema with
* one required property of the given `name`.
*
* @group operations
*/
def field[A](name: String, documentation: Option[String] = None)(implicit
tpe: JsonSchema[A]
): Record[A]
/** The JSON schema of a record with a single optional field `name` of type `A`
*
* - Encoder interpreters can omit the field or emit a field with a `null` value,
* - Decoder interpreters successfully decode `None` if the field is absent or if
* it is present but has the value `null`. They fail if the field is
* present but contains an invalid value,
* - Documentation interpreters produce the JSON schema of a JSON object with an
* optional property of the given `name`.
*
* @group operations
*/
def optField[A](name: String, documentation: Option[String] = None)(implicit
tpe: JsonSchema[A]
): Record[Option[A]]
/** Tags a schema for type `A` with the given tag name */
def taggedRecord[A](recordA: Record[A], tag: String): Tagged[A]
/** Default discriminator field name for sum types.
*
* It defaults to "type", but you can override it twofold:
* - by overriding this field you can change default discriminator name algebra-wide
* - by using `withDiscriminator` you can specify discriminator field name for specific sum type
* @group operations
*/
def defaultDiscriminatorName: String = "type"
/** Allows to specify name of discriminator field for sum type */
def withDiscriminatorTagged[A](
tagged: Tagged[A],
discriminatorName: String
): Tagged[A]
/** The JSON schema of a coproduct made of the given alternative tagged records */
def choiceTagged[A, B](
taggedA: Tagged[A],
taggedB: Tagged[B]
): Tagged[Either[A, B]]
/** The JSON schema of a coproduct that share the same parent type and thus can be widened to that parent type */
def orElseMergeTagged[A: ClassTag, C >: A, B <: C: ClassTag](
taggedA: Tagged[A],
taggedB: Tagged[B]
): Tagged[C] =
choiceTagged(taggedA, taggedB).xmap {
case Left(a) => (a: C)
case Right(b) => (b: C)
} {
case b: B => Right(b)
case a: A => Left(a)
case any =>
throw new IllegalStateException(
s"Could not match: A = ${implicitly[ClassTag[A]]}, B = ${implicitly[
ClassTag[B]
]}, C = ${any.getClass()}"
)
}
/** The JSON schema of a record merging the fields of the two given records */
def zipRecords[A, B](recordA: Record[A], recordB: Record[B])(implicit
t: Tupler[A, B]
): Record[t.Out]
/** Include an example value within the given record JSON schema */
def withExampleRecord[A](record: Record[A], example: A): Record[A]
/** Include an example value within the given tagged JSON schema */
def withExampleTagged[A](tagged: Tagged[A], example: A): Tagged[A]
/** Include an example value within the given enumeration JSON schema */
def withExampleEnum[A](enumeration: Enum[A], example: A): Enum[A]
/** Include an example value within the given JSON schema */
def withExampleJsonSchema[A](schema: JsonSchema[A], example: A): JsonSchema[A]
/** Add a description to the given record JSON schema */
def withDescriptionRecord[A](
record: Record[A],
description: String
): Record[A]
/** Add a description to the given tagged JSON schema */
def withDescriptionTagged[A](
tagged: Tagged[A],
description: String
): Tagged[A]
/** Add a description to the given enumeration JSON schema */
def withDescriptionEnum[A](enumeration: Enum[A], description: String): Enum[A]
/** Add a description to the given JSON schema */
def withDescriptionJsonSchema[A](
schema: JsonSchema[A],
description: String
): JsonSchema[A]
/** Add a title to the given record JSON schema */
def withTitleRecord[A](record: Record[A], title: String): Record[A]
/** Add a title to the given tagged JSON schema */
def withTitleTagged[A](tagged: Tagged[A], title: String): Tagged[A]
/** Add a title to the given enumeration JSON schema */
def withTitleEnum[A](enumeration: Enum[A], title: String): Enum[A]
/** Add a title to the given schema */
def withTitleJsonSchema[A](
schema: JsonSchema[A],
title: String
): JsonSchema[A]
/**
* A schema that can be either `schemaA` or `schemaB`.
*
* Documentation interpreter produce a `oneOf` JSON schema.
* Encoder interpreters forward to either `schemaA` or `schemaB`.
* Decoder interpreters first try to decode with `schemaA`, and fallback to `schemaB`
* in case of failure.
*
* The difference between this operation and the operation `orElse` on “tagged” schemas
* is that this operation does not rely on a discriminator field between the alternative
* schemas. As a consequence, decoding is slower than with “tagged” schemas and provides
* less precise error messages.
*
* @note Be careful to use ''disjoint'' schemas for `A` and `B` (none must be a subtype
* of the other), otherwise, a value of type `B` might also be successfully
* decoded as a value of type `A`, and this could have surprising consequences.
*/
def orFallbackToJsonSchema[A, B](
schemaA: JsonSchema[A],
schemaB: JsonSchema[B]
): JsonSchema[Either[A, B]]
/**
* Documentation related methods for annotating schemas. Encoder and decoder
* interpreters ignore this information.
*/
sealed trait JsonSchemaDocumentationOps[A] {
type Self <: JsonSchema[A]
/**
* Include an example of value in this schema
*
* - Encoder and decoder interpreters ignore this value,
* - Documentation interpreters can show this example value.
*
* @param example Example value to attach to the schema
*/
def withExample(example: A): Self
/**
* Include a description of what this schema represents
*
* - Encoder and decoder interpreters ignore this description,
* - Documentation interpreters can show this description.
*
* @param description information about the values described by the schema
*/
def withDescription(description: String): Self
/**
* Include a title for the schema
*
* - Encoder and decoder interpreters ignore the title,
* - Documentation interpreters can show this title.
*
* @param title short title to attach to the schema
*/
def withTitle(title: String): Self
}
/**
* Implicit methods for values of type [[JsonSchema]]
* @group operations
*/
final implicit class JsonSchemaOps[A](schemaA: JsonSchema[A])
extends JsonSchemaDocumentationOps[A] {
type Self = JsonSchema[A]
def withExample(example: A): JsonSchema[A] =
withExampleJsonSchema(schemaA, example)
def withDescription(description: String): JsonSchema[A] =
withDescriptionJsonSchema(schemaA, description)
def withTitle(title: String): JsonSchema[A] =
withTitleJsonSchema(schemaA, title)
/**
* A schema that can be either `schemaA` or `schemaB`.
*
* - Encoder interpreters forward to `schemaA` to encode a `Left` value or `schemaB`
* to encode a `Right` value,
* - Decoder interpreters first try to decode with `schemaA`, and fallback to `schemaB`
* in case of failure,
* - Documentation interpreter produce a `oneOf` JSON schema.
*
* The difference between this operation and the operation `orElse` on “tagged” schemas
* is that this operation does not rely on a discriminator field between the alternative
* schemas. As a consequence, decoding is slower than with “tagged” schemas and provides
* less precise error messages.
*
* @note Be careful to use ''disjoint'' schemas for `A` and `B` (none must be a subtype
* of the other), otherwise, a value of type `B` might also be successfully
* decoded as a value of type `A`, and this could have surprising consequences.
* @param schemaB fallback schema
*/
def orFallbackTo[B](schemaB: JsonSchema[B]): JsonSchema[Either[A, B]] =
orFallbackToJsonSchema(schemaA, schemaB)
}
/** Implicit methods for values of type [[Record]]
* @group operations
*/
final implicit class RecordOps[A](recordA: Record[A]) extends JsonSchemaDocumentationOps[A] {
type Self = Record[A]
/** Merge the fields of `recordA` with the fields of `recordB`
*
* - Encoder interpreters produce a JSON object with the fields of both
* `recordA` and `recordB`,
* - Decoder interpreters validate the fields of `recordA` and `recordB`,
* - Documentation interpreters produce the schema of a JSON object
* containing the properties of both `recordA` and `recordB`.
*/
def zip[B](recordB: Record[B])(implicit t: Tupler[A, B]): Record[t.Out] =
zipRecords(recordA, recordB)
/** Tag a schema for type `A` with the given tag name
*
* This operation is usually used in conjunction with the `orElse` operation
* on the resulting `Tagged` value, to define a schema that accepts several
* alternatives, discriminated by a type tag.
*
* - Encoder interpreters include a discriminator field to the JSON object
* they produce. The name of the discriminator field is “type”, by default,
* but can be customized by calling the `withDiscriminator` operation or by
* overriding the `defaultDiscriminatorName` operation of the trait `JsonSchemas`,
* - Decoder interpreters validate that the discriminator field is present before
* validating the remaining fields of the object,
* - Documentation interpreters produce a `oneOf` schema listing all the alternative schemas.
*
* @param tag Tag name (e.g., "Rectangle", "Circle")
*/
def tagged(tag: String): Tagged[A] = taggedRecord(recordA, tag)
/**
* Give a name to the schema
*
* - Encoder and decoder interpreters ignore the name,
* - Documentation interpreters use that name to refer to this schema.
*
* @note Names are used by documentation interpreters to construct
* references and the JSON schema specification requires these
* to be valid URI's. Consider using `withTitle` if you just want
* to override the heading displayed in documentation.
*/
def named(name: String): Record[A] = namedRecord(recordA, name)
def withExample(example: A): Record[A] =
withExampleRecord(recordA, example)
def withDescription(description: String): Record[A] =
withDescriptionRecord(recordA, description)
def withTitle(title: String): Record[A] =
withTitleRecord(recordA, title)
}
/** @group operations */
final implicit class TaggedOps[A](taggedA: Tagged[A]) extends JsonSchemaDocumentationOps[A] {
type Self = Tagged[A]
/** Define a schema that alternatively accepts `taggedA` or `taggedB`
*
* - Encoder interpreters forward to `taggedA` to encode a `Left` value or to
* `taggedB` to encode a `Right` value,
* - Decoder interpreters decode the type tag from the JSON object and then
* forward to `taggedA` or `taggedB` according to its value,
* - Documentation interpreters produce a `oneOf` schema listing the alternatives
* in `taggedA` and the alternatives in `taggedB`.
*/
def orElse[B](taggedB: Tagged[B]): Tagged[Either[A, B]] =
choiceTagged(taggedA, taggedB)
/** Define a schema that alternatively accepts `taggedA` or `taggedB` and merges the result
*
* Similar to `orElse` but instead of returning a `Tagged[Either[A, B]]`, it returns
* a `Tagged[C]`, where `C` is a super type of both `A` and `B`.
*
* - Encoder interpreters forward to `taggedA` or `taggedB` based on the runtime type
* information of the value to encode,
* - Decoder interpreters decode the type tag from the JSON object, forward to `taggedA`
* or `taggedB` accordingly, and then widen the result type to `C`,
* - Documentation interpreters produce a `oneOf` schema listing the alternatives
* in `taggedA` and the alternatives in `taggedB`.
*
* @note Encoder interpreters rely on `ClassTag`s to perform the runtime type test
* used for deciding whether to encode the `C` value as an `A` or a `B`.
* Consequently, types `A` and `B` must be distinct after erasure.
* Furthermore, the `orElseMerge` implementation requires the type `B` to
* ''not'' be a supertype of `A`. This should not happen in general. For instance,
* assuming three schemas, `schema1`, `schema2`, and `schema3`, for types having
* a common super-type, if you write `schema1 orElseMerge schema2 orElseMerge schema3`,
* then the right-hand side of `orElseMerge` is always a more specific type than its
* left-hand side.
* However, if you write `schema1 orElseMerge (schema2 orElseMerge schema3)` (note the
* parentheses), then the result of `schema2 orElseMerge schema3` is a super-type of
* `schema1`. In such a case, the `orElseMerge` operation won’t work.
* @see [[https://www.scala-lang.org/api/current/scala/Any.html#isInstanceOf[T0]:Boolean isInstanceOf]] API documentation
*/
def orElseMerge[B <: C, C >: A](
taggedB: Tagged[B]
)(implicit cta: ClassTag[A], ctb: ClassTag[B]): Tagged[C] =
orElseMergeTagged(taggedA, taggedB)
/**
* Give a name to the schema
*
* - Encoder and decoder interpreters ignore the name,
* - Documentation interpreters use that name to refer to this schema.
*
* @note Names are used by documentation interpreters to construct
* references and the JSON schema specification requires these
* to be valid URI's. Consider using `withTitle` if you just want
* to override the heading displayed in documentation.
*/
def named(name: String): Tagged[A] = namedTagged(taggedA, name)
/**
* Override the name of the type discriminator field of this record.
*/
def withDiscriminator(name: String): Tagged[A] =
withDiscriminatorTagged(taggedA, name)
def withExample(example: A): Tagged[A] =
withExampleTagged(taggedA, example)
def withDescription(description: String): Tagged[A] =
withDescriptionTagged(taggedA, description)
def withTitle(title: String): Tagged[A] =
withTitleTagged(taggedA, title)
}
/** @group operations */
final implicit class EnumOps[A](enumA: Enum[A]) extends JsonSchemaDocumentationOps[A] {
type Self = Enum[A]
/**
* Give a name to the schema
*
* - Encoder and decoder interpreters ignore the name,
* - Documentation interpreters use that name to refer to this schema.
*
* @note Names are used by documentation interpreters to construct
* references and the JSON schema specification requires these
* to be valid URI's. Consider using `withTitle` if you just want
* to override the heading displayed in documentation.
*/
def named(name: String): Enum[A] = namedEnum(enumA, name)
def withExample(example: A): Enum[A] =
withExampleEnum(enumA, example)
def withDescription(description: String): Enum[A] =
withDescriptionEnum(enumA, description)
def withTitle(title: String): Enum[A] =
withTitleEnum(enumA, title)
}
/** A JSON schema for type `UUID`
* @group operations
*/
final implicit lazy val uuidJsonSchema: JsonSchema[UUID] =
stringJsonSchema(format = Some("uuid")).xmapPartial { str =>
Validated.fromEither(
Exception.nonFatalCatch
.either(UUID.fromString(str))
.left
.map(_ => s"Invalid UUID value: '$str'" :: Nil)
)
}(_.toString)
/**
* A JSON schema for type `String`.
*
* @param format An additional semantic information about the underlying format of the string
* @see [[https://json-schema.org/understanding-json-schema/reference/string.html#format]]
* @group operations
*/
def stringJsonSchema(format: Option[String]): JsonSchema[String]
/** A JSON schema for type `String`
* @group operations
*/
final implicit def defaultStringJsonSchema: JsonSchema[String] =
stringJsonSchema(format = None)
/** A JSON schema for type `Int`
* @group operations
*/
implicit def intJsonSchema: JsonSchema[Int]
/** A JSON schema for type `Long`
* @group operations
*/
implicit def longJsonSchema: JsonSchema[Long]
/** A JSON schema for type `BigDecimal`
* @group operations
*/
implicit def bigdecimalJsonSchema: JsonSchema[BigDecimal]
/** A JSON schema for type `Float`
* @group operations
*/
implicit def floatJsonSchema: JsonSchema[Float]
/** A JSON schema for type `Double`
* @group operations
*/
implicit def doubleJsonSchema: JsonSchema[Double]
/** A JSON schema for type `Boolean`
* @group operations
*/
implicit def booleanJsonSchema: JsonSchema[Boolean]
/** A JSON schema for type `Byte`
* @group operations
*/
implicit def byteJsonSchema: JsonSchema[Byte]
/** A JSON schema for sequences
* @group operations
*/
implicit def arrayJsonSchema[C[X] <: Seq[X], A](implicit
jsonSchema: JsonSchema[A],
factory: Factory[A, C[A]]
): JsonSchema[C[A]]
/** A JSON schema for maps with string keys
* @group operations
*/
implicit def mapJsonSchema[A](implicit
jsonSchema: JsonSchema[A]
): JsonSchema[Map[String, A]]
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy