endpoints4s.playjson.JsonSchemas.scala Maven / Gradle / Ivy
The newest version!
package endpoints4s.playjson
import endpoints4s.algebra.JsonSchemas.PreciseField
import endpoints4s.{
MultipleOf,
NumericConstraints,
PartialInvariantFunctor,
Tupler,
Validated,
algebra
}
import play.api.libs.functional.syntax._
import play.api.libs.json._
import scala.collection.compat._
/** An interpreter for [[endpoints4s.algebra.JsonSchemas]] that produces Play JSON `play.api.libs.json.Reads`
* and `play.api.libs.json.Writes`.
*/
trait JsonSchemas extends algebra.NoDocsJsonSchemas with TuplesSchemas {
trait JsonSchema[A] {
def reads: Reads[A]
def writes: Writes[A]
}
object JsonSchema {
def apply[A](_reads: Reads[A], _writes: Writes[A]): JsonSchema[A] =
new JsonSchema[A] {
def reads: Reads[A] = _reads
def writes: Writes[A] = _writes
}
implicit def toPlayJsonFormat[A](implicit
jsonSchema: JsonSchema[A]
): Format[A] =
Format(jsonSchema.reads, jsonSchema.writes)
}
implicit def jsonSchemaPartialInvFunctor: PartialInvariantFunctor[JsonSchema] =
new PartialInvariantFunctor[JsonSchema] {
def xmapPartial[A, B](
fa: JsonSchema[A],
f: A => Validated[B],
g: B => A
): JsonSchema[B] =
JsonSchema(
fa.reads.flatMap(a =>
f(a).fold(
Reads.pure(_),
errors => Reads.failed(errors.mkString(". "))
)
),
fa.writes.contramap(g)
)
override def xmap[A, B](
fa: JsonSchema[A],
f: A => B,
g: B => A
): JsonSchema[B] =
JsonSchema(fa.reads.map(f), fa.writes.contramap(g))
}
trait Record[A] extends JsonSchema[A] {
override def writes: OWrites[A]
}
object Record {
def apply[A](_reads: Reads[A], _writes: OWrites[A]): Record[A] =
new Record[A] {
def reads: Reads[A] = _reads
def writes: OWrites[A] = _writes
}
implicit def toPlayJsonOFormat[A](implicit record: Record[A]): OFormat[A] =
OFormat(record.reads, record.writes)
}
implicit def recordPartialInvFunctor: PartialInvariantFunctor[Record] =
new PartialInvariantFunctor[Record] {
def xmapPartial[A, B](
fa: Record[A],
f: A => Validated[B],
g: B => A
): Record[B] =
Record(
fa.reads.flatMap(a =>
f(a).fold(
Reads.pure(_),
errors => Reads.failed(errors.mkString(". "))
)
),
fa.writes.contramap(g)
)
override def xmap[A, B](fa: Record[A], f: A => B, g: B => A): Record[B] =
Record(fa.reads.map(f), fa.writes.contramap(g))
}
type Enum[A] = JsonSchema[A]
def enumeration[A](values: Seq[A])(jsonSchema: JsonSchema[A]): Enum[A] = {
JsonSchema(
jsonSchema.reads.flatMap { a =>
if (values.contains(a)) {
Reads.pure(a)
} else {
Reads.failed(
s"Invalid value: ${Json.stringify(jsonSchema.writes.writes(a))} ; valid values are: ${values
.map(a => Json.stringify(jsonSchema.writes.writes(a)))
.mkString(", ")}"
)
}
},
jsonSchema.writes
)
}
override def lazySchema[A](name: String)(schema: => JsonSchema[A]): JsonSchema[A] = {
// The schema won’t be evaluated until its `reads` or `writes` is effectively used
lazy val evaluatedSchema = schema
new JsonSchema[A] {
def reads: Reads[A] = Reads(js => evaluatedSchema.reads.reads(js))
def writes: Writes[A] = Writes(a => evaluatedSchema.writes.writes(a))
}
}
def lazyRecord[A](schema: => Record[A], name: String): JsonSchema[A] =
lazySchema(name)(schema)
def lazyTagged[A](schema: => Tagged[A], name: String): JsonSchema[A] =
lazySchema(name)(schema)
override def lazyRecord[A](name: String)(schema: => Record[A]): Record[A] = {
// The schema won’t be evaluated until its `reads` or `writes` is effectively used
lazy val evaluatedSchema = schema
Record(
Reads(js => evaluatedSchema.reads.reads(js)),
OWrites(a => evaluatedSchema.writes.writes(a))
)
}
override def lazyTagged[A](name: String)(schema: => Tagged[A]): Tagged[A] = {
// The schema won’t be evaluated until its `reads` or `writes` is effectively used
lazy val evaluatedSchema = schema
new Tagged[A] {
override def discriminator: String =
evaluatedSchema.discriminator
def tagAndJson(a: A): (String, JsObject) =
evaluatedSchema.tagAndJson(a)
def findReads(tagName: String): Option[Reads[A]] =
evaluatedSchema.findReads(tagName)
}
}
def emptyRecord: Record[Unit] =
Record(
new Reads[Unit] {
def reads(json: JsValue): JsResult[Unit] =
json match {
case JsObject(_) => JsSuccess(())
case _ => JsError(s"Invalid JSON object: $json")
}
},
new OWrites[Unit] {
def writes(o: Unit): JsObject = Json.obj()
}
)
def field[A](name: String, documentation: Option[String] = None)(implicit
tpe: JsonSchema[A]
): Record[A] =
Record(
(__ \ name).read(tpe.reads),
(__ \ name).write(tpe.writes)
)
def optField[A](name: String, documentation: Option[String] = None)(implicit
tpe: JsonSchema[A]
): Record[Option[A]] =
Record(
(__ \ name).readNullable(tpe.reads),
(__ \ name).writeNullable(tpe.writes)
)
override def preciseField[A](name: String, documentation: Option[String] = None)(implicit
tpe: JsonSchema[A]
): Record[PreciseField[A]] = {
val path = __ \ name
Record(
Reads[PreciseField[A]] { json =>
path.asSingleJson(json) match {
case _: JsUndefined => JsSuccess(PreciseField.Absent)
case JsDefined(JsNull) => JsSuccess(PreciseField.Null)
case JsDefined(value) =>
tpe.reads.reads(value).repath(path).map(PreciseField.Present.apply)
}
},
OWrites[PreciseField[A]] {
case PreciseField.Absent => JsObject.empty
case PreciseField.Null => JsPath.createObj(path -> JsNull)
case PreciseField.Present(value) => JsPath.createObj(path -> tpe.writes.writes(value))
}
)
}
def orFallbackToJsonSchema[A, B](
schemaA: JsonSchema[A],
schemaB: JsonSchema[B]
): JsonSchema[Either[A, B]] = {
val reads =
schemaA.reads
.map[Either[A, B]](Left(_))
.orElse(schemaB.reads.map[Either[A, B]](Right(_)))
.orElse(Reads(json => JsError(s"Invalid value: $json")))
val writes =
Writes[Either[A, B]] {
case Left(a) => schemaA.writes.writes(a)
case Right(b) => schemaB.writes.writes(b)
}
JsonSchema(reads, writes)
}
def stringJsonSchema(format: Option[String]): JsonSchema[String] =
JsonSchema(implicitly, implicitly)
private def getReads[A: Reads: MultipleOf: Ordering](constraints: NumericConstraints[A]) =
implicitly[Reads[A]].flatMap { value =>
val r2: Reads[A] = /* explicit type annotation as otherwise the compiler fails */
if (constraints.satisfiedBy(value)) Reads.pure(value)
else Reads.failed(s"$value does not satisfy the constraints: $constraints")
r2
}
implicit lazy val intJsonSchema: JsonSchema[Int] =
intWithConstraintsJsonSchema(NumericConstraints[Int])
implicit lazy val longJsonSchema: JsonSchema[Long] =
longWithConstraintsJsonSchema(NumericConstraints[Long])
implicit lazy val bigdecimalJsonSchema: JsonSchema[BigDecimal] =
bigdecimalWithConstraintsJsonSchema(NumericConstraints[BigDecimal])
implicit lazy val floatJsonSchema: JsonSchema[Float] =
floatWithConstraintsJsonSchema(NumericConstraints[Float])
implicit lazy val doubleJsonSchema: JsonSchema[Double] =
doubleWithConstraintsJsonSchema(NumericConstraints[Double])
override def intWithConstraintsJsonSchema(constraints: NumericConstraints[Int]): JsonSchema[Int] =
JsonSchema(getReads(constraints), implicitly)
override def longWithConstraintsJsonSchema(
constraints: NumericConstraints[Long]
): JsonSchema[Long] =
JsonSchema(getReads(constraints), implicitly)
override def bigdecimalWithConstraintsJsonSchema(
constraints: NumericConstraints[BigDecimal]
): JsonSchema[BigDecimal] =
JsonSchema(getReads(constraints), implicitly)
override def floatWithConstraintsJsonSchema(
constraints: NumericConstraints[Float]
): JsonSchema[Float] =
JsonSchema(getReads(constraints), implicitly)
override def doubleWithConstraintsJsonSchema(
constraints: NumericConstraints[Double]
): JsonSchema[Double] =
JsonSchema(getReads(constraints), implicitly)
implicit def booleanJsonSchema: JsonSchema[Boolean] =
JsonSchema(implicitly, implicitly)
implicit def byteJsonSchema: JsonSchema[Byte] =
JsonSchema(implicitly, implicitly)
implicit def arrayJsonSchema[C[X] <: Iterable[X], A](implicit
jsonSchema: JsonSchema[A],
factory: Factory[A, C[A]]
): JsonSchema[C[A]] =
JsonSchema[C[A]](
Reads.traversableReads(factory, jsonSchema.reads),
Writes.iterableWrites2[A, C[A]](implicitly, jsonSchema.writes)
)
implicit def mapJsonSchema[A](implicit
jsonSchema: JsonSchema[A]
): JsonSchema[Map[String, A]] =
JsonSchema(
Reads.mapReads(jsonSchema.reads),
Writes.genericMapWrites(jsonSchema.writes)
)
def zipRecords[A, B](recordA: Record[A], recordB: Record[B])(implicit
t: Tupler[A, B]
): Record[t.Out] = {
val reads = (recordA.reads and recordB.reads).tupled.map { case (a, b) =>
t(a, b)
}
val writes = new OWrites[t.Out] {
override def writes(o: t.Out): JsObject =
t.unapply(o) match {
case (a, b) =>
recordA.writes.writes(a) deepMerge recordB.writes.writes(b)
}
}
Record(reads, writes)
}
trait Tagged[A] extends Record[A] {
def discriminator: String = defaultDiscriminatorName
def tagAndJson(a: A): (String, JsObject)
def findReads(tagName: String): Option[Reads[A]]
final def reads: Reads[A] = {
case jsObject @ JsObject(kvs) =>
kvs.get(discriminator) match {
case Some(JsString(tag)) =>
findReads(tag) match {
case Some(reads) => reads.reads(jsObject)
case None => JsError(s"no Reads for tag '$tag': $jsObject")
}
case _ =>
JsError(
s"expected discriminator field '$discriminator', but not found in: $jsObject"
)
}
case json =>
JsError(s"expected JSON object for tagged type, but found: $json")
}
final def writes: OWrites[A] =
new OWrites[A] {
override def writes(a: A): JsObject = {
val (tag, json) = tagAndJson(a)
json + (discriminator -> JsString(tag))
}
}
}
implicit def taggedPartialInvFunctor: PartialInvariantFunctor[Tagged] =
new PartialInvariantFunctor[Tagged] {
def xmapPartial[A, B](
fa: Tagged[A],
f: A => Validated[B],
g: B => A
): Tagged[B] =
new Tagged[B] {
def tagAndJson(b: B): (String, JsObject) = fa.tagAndJson(g(b))
def findReads(tag: String): Option[Reads[B]] =
fa.findReads(tag)
.map(
_.flatMap(a =>
f(a).fold(
Reads.pure(_),
errors => Reads.failed(errors.mkString(". "))
)
)
)
}
override def xmap[A, B](fa: Tagged[A], f: A => B, g: B => A): Tagged[B] =
new Tagged[B] {
def tagAndJson(b: B): (String, JsObject) = fa.tagAndJson(g(b))
def findReads(tag: String): Option[Reads[B]] =
fa.findReads(tag).map(_.map(f))
}
}
def taggedRecord[A](recordA: Record[A], tag: String): Tagged[A] =
new Tagged[A] {
def tagAndJson(a: A): (String, JsObject) = (tag, recordA.writes.writes(a))
def findReads(tagName: String): Option[Reads[A]] =
if (tag == tagName) Some(recordA.reads) else None
}
def withDiscriminatorTagged[A](
tagged: Tagged[A],
discriminatorName: String
): Tagged[A] =
new Tagged[A] {
override def discriminator: String = discriminatorName
def tagAndJson(a: A): (String, JsObject) = tagged.tagAndJson(a)
def findReads(tagName: String): Option[Reads[A]] =
tagged.findReads(tagName)
}
def choiceTagged[A, B](
taggedA: Tagged[A],
taggedB: Tagged[B]
): Tagged[Either[A, B]] =
new Tagged[Either[A, B]] {
def tagAndJson(aOrB: Either[A, B]): (String, JsObject) =
aOrB match {
case Left(a) => taggedA.tagAndJson(a)
case Right(b) => taggedB.tagAndJson(b)
}
def findReads(tagName: String): Option[Reads[Either[A, B]]] =
taggedA.findReads(tagName).map(_.map[Either[A, B]](Left(_))) orElse
taggedB.findReads(tagName).map(_.map[Either[A, B]](Right(_)))
}
}