Skip to content

Commit

Permalink
Support referential transparency for records
Browse files Browse the repository at this point in the history
In a nutshell:

  - adds a new `DocumentedRecord` subclass for representing overrides of
    examples, descriptions, etc. on named schemas

  - renders that subclass using `allOf` with the base schema

Note: this is a prototype - it doesn't solve referential transparency
for names anywhere else (and it is likely that doing so would lead to
more subclasses for the other documented JSON schema types).

See endpoints4s#888
  • Loading branch information
harpocrates committed Mar 24, 2022
1 parent 32180e1 commit ef9aaa7
Show file tree
Hide file tree
Showing 4 changed files with 80 additions and 33 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,11 @@ trait JsonSchemasFixtures extends JsonSchemas {
val recursiveSchema: Record[Recursive] =
lazyRecord("Rec")(
optField("next")(recursiveSchema)
).xmap(Recursive(_))(_.next)
.withDescription("Rec description")
.withTitle("Rec title")
.withExample(Recursive(None))
.xmap(Recursive(_))(_.next)
.withDescription("Rec description")
.withTitle("Rec title")
.withExample(Recursive(None))
)

sealed trait Expression
object Expression {
Expand All @@ -133,19 +134,19 @@ trait JsonSchemasFixtures extends JsonSchemas {
case Expression.Literal(value) => Left(value)
case Expression.Add(x, y) => Right((x, y))
}
.withDescription("Expression description")
.withTitle("Expression title")
.withExample(Expression.Literal(1))
}
.withDescription("Expression description")
.withTitle("Expression title")
.withExample(Expression.Literal(1))

case class MutualRecursiveA(b: Option[MutualRecursiveB])
case class MutualRecursiveB(a: Option[MutualRecursiveA])
val mutualRecursiveA: JsonSchema[MutualRecursiveA] = lazySchema("MutualRecursiveA")(
optField("b")(mutualRecursiveB)
).xmap(MutualRecursiveA(_))(_.b)
optField("b")(mutualRecursiveB).xmap(MutualRecursiveA(_))(_.b)
)
val mutualRecursiveB: JsonSchema[MutualRecursiveB] = lazySchema("MutualRecursiveB")(
optField("a")(mutualRecursiveA)
).xmap(MutualRecursiveB(_))(_.a)
optField("a")(mutualRecursiveA).xmap(MutualRecursiveB(_))(_.a)
)

sealed trait TaggedRecursive extends Product with Serializable
case class TaggedRecursiveA(a: String, next: Option[TaggedRecursive]) extends TaggedRecursive
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -254,10 +254,7 @@ trait EndpointsWithCustomErrors
}
alternativeSchemas.flatMap(captureReferencedSchemasRec)
case allOf: Schema.AllOf =>
allOf.schemas.flatMap {
case _: Schema.Reference => Nil
case s => captureReferencedSchemasRec(s)
}
allOf.schemas.flatMap(captureReferencedSchemasRec)
case referenced: Schema.Reference =>
referenced +: referenced.original
.map(captureReferencedSchemasRec)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,13 +88,58 @@ trait JsonSchemas extends algebra.JsonSchemas with TuplesSchemas {
title: Option[String] = None
) extends DocumentedRecord {
def withName(name: String): DocumentedRecord =
copy(name = Some(name))
if (this.name.nonEmpty) new Overriding(this, name = Some(name))
else copy(name = Some(name))
def withExample(example: => ujson.Value): DocumentedRecord =
copy(example = Some(example))
if (name.nonEmpty) new Overriding(this, exampleUnshared = Some(example))
else copy(example = Some(example))
def withTitle(title: String): DocumentedRecord =
copy(title = Some(title))
if (name.nonEmpty) new Overriding(this, title = Some(title))
else copy(title = Some(title))
def withDescription(description: String): DocumentedRecord =
copy(description = Some(description))
if (name.nonEmpty) new Overriding(this, description = Some(description))
else copy(description = Some(description))
}

class Overriding(
val underlying: DocumentedRecord,
val additionalProperties: Option[DocumentedJsonSchema] = None,
val name: Option[String] = None,
val description: Option[String] = None,
exampleUnshared: => Option[ujson.Value] = None,
val title: Option[String] = None
) extends DocumentedRecord {
lazy val example = exampleUnshared

val fields: List[Field] = Nil
def withName(name: String): DocumentedRecord =
if (this.name.nonEmpty) new Overriding(this, name = Some(name))
else copy(name = Some(name))
def withExample(example: => ujson.Value): DocumentedRecord =
if (name.nonEmpty) new Overriding(this, exampleUnshared = Some(example))
else copy(example = Some(example))
def withTitle(title: String): DocumentedRecord =
if (name.nonEmpty) new Overriding(this, title = Some(title))
else copy(title = Some(title))
def withDescription(description: String): DocumentedRecord =
if (name.nonEmpty) new Overriding(this, description = Some(description))
else copy(description = Some(description))

private def copy(
underlying: DocumentedRecord = underlying,
additionalProperties: Option[DocumentedJsonSchema] = additionalProperties,
name: Option[String] = name,
description: Option[String] = description,
example: => Option[ujson.Value] = exampleUnshared,
title: Option[String] = title
): Overriding = new Overriding(
underlying,
additionalProperties,
name,
description,
example,
title
)
}

class Lazy(n: String, docs: => DocumentedRecord) extends DocumentedRecord {
Expand All @@ -108,19 +153,13 @@ trait JsonSchemas extends algebra.JsonSchemas with TuplesSchemas {
def title: Option[String] = evaluatedDocs.title

def withName(n: String): DocumentedRecord =
new Lazy(n, docs)
new Overriding(this, name = Some(n))
def withExample(e: => ujson.Value): DocumentedRecord =
new Lazy(n, this) {
override def example: Option[ujson.Value] = Some(e)
}
new Overriding(this, exampleUnshared = Some(e))
def withTitle(t: String): DocumentedRecord =
new Lazy(n, this) {
override def title: Option[String] = Some(t)
}
new Overriding(this, title = Some(t))
def withDescription(d: String): DocumentedRecord =
new Lazy(n, this) {
override def description: Option[String] = Some(d)
}
new Overriding(this, description = Some(d))
}

def apply(
Expand Down Expand Up @@ -1096,6 +1135,14 @@ trait JsonSchemas extends algebra.JsonSchemas with TuplesSchemas {
coprodBase: Option[(String, DocumentedCoProd)],
referencedSchemas: Set[String]
): Schema = {

val underlyingSchema: Option[Schema] = record match {
case overriding: DocumentedRecord.Overriding =>
Some(toSchema(overriding.underlying, None, referencedSchemas))
case _ =>
None
}

val fieldsSchema = record.fields
.map(f =>
Schema.Property(
Expand All @@ -1111,13 +1158,14 @@ trait JsonSchemas extends algebra.JsonSchemas with TuplesSchemas {
record.additionalProperties.map(toSchema(_, None, referencedSchemas))

coprodBase.fold[Schema] {
Schema.Object(
val baseSchema = Schema.Object(
fieldsSchema,
additionalProperties,
record.description,
record.example,
record.title
)
underlyingSchema.fold[Schema](baseSchema)(s => Schema.AllOf(schemas = List(s, baseSchema), None, None, None))
} { case (tag, coprod) =>
val discriminatorField =
Schema.Property(
Expand Down Expand Up @@ -1147,20 +1195,21 @@ trait JsonSchemas extends algebra.JsonSchemas with TuplesSchemas {
None,
None
)
),
) ++ underlyingSchema.toList,
record.description,
record.example,
record.title
)

case _ =>
Schema.Object(
val baseSchema = Schema.Object(
discriminatorField :: fieldsSchema,
additionalProperties,
record.description,
record.example,
record.title
)
underlyingSchema.fold[Schema](baseSchema)(s => Schema.AllOf(schemas = List(s, baseSchema), None, None, None))
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import org.scalatest.wordspec.AnyWordSpec

class SumTypedRequests extends AnyWordSpec with Matchers {

"Request bondy content" should {
"Request body content" should {

"Include all supported content-types" in new Fixtures {
checkRequestContentTypes(sumTypedEndpoint)(
Expand Down

0 comments on commit ef9aaa7

Please sign in to comment.