From 58e059401f8c4b89ec655cdedb3044676069ff4b Mon Sep 17 00:00:00 2001 From: Nabil Abdel-Hafeez <7283535+987Nabil@users.noreply.github.com> Date: Wed, 29 Nov 2023 10:26:22 +0100 Subject: [PATCH] Improve OpenAPI model; Add OpenAPI generator for EndpointAPI (#1498) (#2470) * Improve OpenAPI model; Add OpenAPI generator for EndpointAPI (#1498) * Minimize schema for optional fields * Integrate main changes * Fix Scala 3 build * Fix exhaustive matching * More OpenAPI generation tests * Use latest zio-schema snapshot for Scala 3 macro derivation fix * Formatting * OpenAPI tests now compare json ASTs, to avoid string render differences * Refactoring * improve docs (#2482) * Add a test of a middleware providing a context to a `Routes` (#2487) * Add a test of a middleware providing a context to a `Routes` * Add a test of a middleware providing a context to a `Routes` * scalafmt * scalafmt * Remove usage of deprecated method in build.sbt (#2486) * Update sbt-github-actions to 0.18.0 (#2484) * Update sbt-github-actions to 0.18.0 * Regenerate GitHub Actions workflow Executed command: sbt githubWorkflowGenerate * Update netty-codec-http, ... to 4.1.100.Final (#2485) * Generate readme * OpenAPI gen support for all kinds of enums with(out) discriminators OpenAPI gen support for default values, optional and transient fields --------- Co-authored-by: TomTriple Co-authored-by: Jules Ivanic Co-authored-by: Scala Steward <43047562+scala-steward@users.noreply.github.com> --- .../zio/http/endpoint/cli/HttpOptions.scala | 29 +- .../zio/http/endpoint/cli/CommandGen.scala | 23 +- .../src/main/scala/zio/http/Middleware.scala | 2 +- zio-http/src/main/scala/zio/http/Status.scala | 5 + .../src/main/scala/zio/http/codec/Doc.scala | 18 + .../main/scala/zio/http/codec/HttpCodec.scala | 15 + .../main/scala/zio/http/codec/PathCodec.scala | 61 +- .../scala/zio/http/codec/SegmentCodec.scala | 94 +- .../zio/http/codec/internal/BodyCodec.scala | 4 +- .../scala/zio/http/endpoint/Endpoint.scala | 6 +- .../http/endpoint/openapi/JsonRenderer.scala | 143 -- .../http/endpoint/openapi/JsonSchema.scala | 910 +++++++ .../zio/http/endpoint/openapi/OpenAPI.scala | 1371 +++++----- .../http/endpoint/openapi/OpenAPIGen.scala | 819 ++++++ .../endpoint/openapi/JsonRendererSpec.scala | 191 -- .../endpoint/openapi/OpenAPIGenSpec.scala | 2233 +++++++++++++++++ 16 files changed, 4817 insertions(+), 1107 deletions(-) delete mode 100644 zio-http/src/main/scala/zio/http/endpoint/openapi/JsonRenderer.scala create mode 100644 zio-http/src/main/scala/zio/http/endpoint/openapi/JsonSchema.scala create mode 100644 zio-http/src/main/scala/zio/http/endpoint/openapi/OpenAPIGen.scala delete mode 100644 zio-http/src/test/scala/zio/http/endpoint/openapi/JsonRendererSpec.scala create mode 100644 zio-http/src/test/scala/zio/http/endpoint/openapi/OpenAPIGenSpec.scala diff --git a/zio-http-cli/src/main/scala/zio/http/endpoint/cli/HttpOptions.scala b/zio-http-cli/src/main/scala/zio/http/endpoint/cli/HttpOptions.scala index daba42a562..2d855e3c17 100644 --- a/zio-http-cli/src/main/scala/zio/http/endpoint/cli/HttpOptions.scala +++ b/zio-http-cli/src/main/scala/zio/http/endpoint/cli/HttpOptions.scala @@ -1,7 +1,6 @@ package zio.http.endpoint.cli -import java.nio.file.Path - +import scala.annotation.tailrec import scala.language.implicitConversions import scala.util.Try @@ -242,8 +241,8 @@ private[cli] object HttpOptions { self => override val name = pathCodec.segments.map { - case SegmentCodec.Literal(value, _) => value - case _ => "" + case SegmentCodec.Literal(value) => value + case _ => "" } .filter(_ != "") .mkString("-") @@ -301,7 +300,7 @@ private[cli] object HttpOptions { Try(java.util.UUID.fromString(str)).toEither.left.map { error => ValidationError( ValidationErrorType.InvalidValue, - HelpDoc.p(HelpDoc.Span.code(error.getMessage())), + HelpDoc.p(HelpDoc.Span.code(error.getMessage)), ) }, ) @@ -313,27 +312,29 @@ private[cli] object HttpOptions { } private[cli] def optionsFromSegment(segment: SegmentCodec[_]): Options[String] = { + @tailrec def fromSegment[A](segment: SegmentCodec[A]): Options[String] = segment match { - case SegmentCodec.UUID(name, doc) => + case SegmentCodec.UUID(name) => Options .text(name) .mapOrFail(str => Try(java.util.UUID.fromString(str)).toEither.left.map { error => ValidationError( ValidationErrorType.InvalidValue, - HelpDoc.p(HelpDoc.Span.code(error.getMessage())), + HelpDoc.p(HelpDoc.Span.code(error.getMessage)), ) }, ) .map(_.toString) - case SegmentCodec.Text(name, doc) => Options.text(name) - case SegmentCodec.IntSeg(name, doc) => Options.integer(name).map(_.toInt).map(_.toString) - case SegmentCodec.LongSeg(name, doc) => Options.integer(name).map(_.toInt).map(_.toString) - case SegmentCodec.BoolSeg(name, doc) => Options.boolean(name).map(_.toString) - case SegmentCodec.Literal(value, doc) => Options.Empty.map(_ => value) - case SegmentCodec.Trailing(doc) => Options.none.map(_.toString) - case SegmentCodec.Empty(_) => Options.none.map(_.toString) + case SegmentCodec.Text(name) => Options.text(name) + case SegmentCodec.IntSeg(name) => Options.integer(name).map(_.toInt).map(_.toString) + case SegmentCodec.LongSeg(name) => Options.integer(name).map(_.toInt).map(_.toString) + case SegmentCodec.BoolSeg(name) => Options.boolean(name).map(_.toString) + case SegmentCodec.Literal(value) => Options.Empty.map(_ => value) + case SegmentCodec.Trailing => Options.none.map(_.toString) + case SegmentCodec.Empty => Options.none.map(_.toString) + case SegmentCodec.Annotated(codec, _) => fromSegment(codec) } fromSegment(segment) diff --git a/zio-http-cli/src/test/scala/zio/http/endpoint/cli/CommandGen.scala b/zio-http-cli/src/test/scala/zio/http/endpoint/cli/CommandGen.scala index 4b61db919e..53334db6c0 100644 --- a/zio-http-cli/src/test/scala/zio/http/endpoint/cli/CommandGen.scala +++ b/zio-http-cli/src/test/scala/zio/http/endpoint/cli/CommandGen.scala @@ -1,6 +1,7 @@ package zio.http.endpoint.cli -import zio.ZNothing +import scala.annotation.tailrec + import zio.cli._ import zio.test._ @@ -9,7 +10,6 @@ import zio.schema._ import zio.http._ import zio.http.codec._ import zio.http.endpoint._ -import zio.http.endpoint.cli.AuxGen._ import zio.http.endpoint.cli.CliRepr.HelpRepr import zio.http.endpoint.cli.EndpointGen._ @@ -20,17 +20,20 @@ import zio.http.endpoint.cli.EndpointGen._ object CommandGen { def getSegment(segment: SegmentCodec[_]): (String, String) = { + @tailrec def fromSegment[A](segment: SegmentCodec[A]): (String, String) = segment match { - case SegmentCodec.UUID(name, doc) => (name, "text") - case SegmentCodec.Text(name, doc) => (name, "text") - case SegmentCodec.IntSeg(name, doc) => (name, "integer") - case SegmentCodec.LongSeg(name, doc) => (name, "integer") - case SegmentCodec.BoolSeg(name, doc) => (name, "boolean") - case SegmentCodec.Literal(value, doc) => ("", "") - case SegmentCodec.Trailing(doc) => ("", "") - case SegmentCodec.Empty(_) => ("", "") + case SegmentCodec.UUID(name) => (name, "text") + case SegmentCodec.Text(name) => (name, "text") + case SegmentCodec.IntSeg(name) => (name, "integer") + case SegmentCodec.LongSeg(name) => (name, "integer") + case SegmentCodec.BoolSeg(name) => (name, "boolean") + case SegmentCodec.Literal(_) => ("", "") + case SegmentCodec.Trailing => ("", "") + case SegmentCodec.Empty => ("", "") + case SegmentCodec.Annotated(codec, _) => fromSegment(codec) } + fromSegment(segment) } diff --git a/zio-http/src/main/scala/zio/http/Middleware.scala b/zio-http/src/main/scala/zio/http/Middleware.scala index 1324f9a81c..cffd24c4dc 100644 --- a/zio-http/src/main/scala/zio/http/Middleware.scala +++ b/zio-http/src/main/scala/zio/http/Middleware.scala @@ -357,7 +357,7 @@ object Middleware extends HandlerAspects { if (isFishy) { Handler.fromZIO(ZIO.logWarning(s"fishy request detected: ${request.path.encode}")) *> Handler.badRequest } else { - val segs = pattern.pathCodec.segments.collect { case SegmentCodec.Literal(v, _) => + val segs = pattern.pathCodec.segments.collect { case SegmentCodec.Literal(v) => v } val unnest = segs.foldLeft(Path.empty)(_ / _).addLeadingSlash diff --git a/zio-http/src/main/scala/zio/http/Status.scala b/zio-http/src/main/scala/zio/http/Status.scala index 28fb39c2d1..2eade63153 100644 --- a/zio-http/src/main/scala/zio/http/Status.scala +++ b/zio-http/src/main/scala/zio/http/Status.scala @@ -16,6 +16,8 @@ package zio.http +import scala.util.Try + import zio.Trace import zio.stacktracer.TracingImplicits.disableAutoTrace @@ -170,6 +172,9 @@ object Status { final case class Custom(override val code: Int) extends Status + def fromString(code: String): Option[Status] = + Try(code.toInt).toOption.flatMap(fromInt) + def fromInt(code: Int): Option[Status] = { if (code < 100 || code > 599) { diff --git a/zio-http/src/main/scala/zio/http/codec/Doc.scala b/zio-http/src/main/scala/zio/http/codec/Doc.scala index 4b3cf5a01c..af25f719bd 100644 --- a/zio-http/src/main/scala/zio/http/codec/Doc.scala +++ b/zio-http/src/main/scala/zio/http/codec/Doc.scala @@ -16,6 +16,11 @@ package zio.http.codec +import zio.Chunk +import zio.stacktracer.TracingImplicits.disableAutoTrace + +import zio.schema.Schema + import zio.http.codec.Doc.Span.CodeStyle import zio.http.template @@ -42,6 +47,13 @@ sealed trait Doc { self => case _ => false } + private[zio] def flattened: Chunk[Doc] = + self match { + case Doc.Empty => Chunk.empty + case Doc.Sequence(left, right) => left.flattened ++ right.flattened + case x => Chunk(x) + } + def toCommonMark: String = { val writer = new StringBuilder @@ -315,6 +327,12 @@ sealed trait Doc { self => } object Doc { + implicit val schemaDocSchema: Schema[Doc] = + Schema[String].transform( + fromCommonMark, + _.toCommonMark, + ) + def fromCommonMark(commonMark: String): Doc = Doc.Raw(commonMark, RawDocType.CommonMark) diff --git a/zio-http/src/main/scala/zio/http/codec/HttpCodec.scala b/zio-http/src/main/scala/zio/http/codec/HttpCodec.scala index 6e49a16009..a5e2147743 100644 --- a/zio-http/src/main/scala/zio/http/codec/HttpCodec.scala +++ b/zio-http/src/main/scala/zio/http/codec/HttpCodec.scala @@ -18,6 +18,7 @@ package zio.http.codec import java.util.concurrent.ConcurrentHashMap +import scala.annotation.tailrec import scala.language.implicitConversions import scala.reflect.ClassTag @@ -192,6 +193,18 @@ sealed trait HttpCodec[-AtomTypes, Value] { ): Task[Value] = encoderDecoder(Chunk.empty).decode(url, status, method, headers, body) + def doc: Option[Doc] = { + @tailrec + def loop(codec: HttpCodec[_, _]): Option[Doc] = + codec match { + case Annotated(_, Metadata.Documented(doc)) => Some(doc) + case Annotated(codec, _) => loop(codec) + case _ => None + } + + loop(self) + } + /** * Uses this codec to encode the Scala value into a request. */ @@ -630,6 +643,8 @@ object HttpCodec extends ContentCodecs with HeaderCodecs with MethodCodecs with final case class Examples[A](examples: Map[String, A]) extends Metadata[A] final case class Documented[A](doc: Doc) extends Metadata[A] + + final case class Deprecated[A](doc: Doc) extends Metadata[A] } private[http] final case class TransformOrFail[AtomType, X, A]( diff --git a/zio-http/src/main/scala/zio/http/codec/PathCodec.scala b/zio-http/src/main/scala/zio/http/codec/PathCodec.scala index ea56ec1905..85e74f8a15 100644 --- a/zio-http/src/main/scala/zio/http/codec/PathCodec.scala +++ b/zio-http/src/main/scala/zio/http/codec/PathCodec.scala @@ -16,12 +16,10 @@ package zio.http.codec -import scala.annotation.tailrec import scala.collection.immutable.ListMap import scala.language.implicitConversions -import zio.stacktracer.TracingImplicits.disableAutoTrace -import zio.{Chunk, NonEmptyChunk} +import zio._ import zio.http.Path @@ -239,7 +237,7 @@ sealed trait PathCodec[A] { self => rightPath <- loop(right, rightValue) } yield leftPath ++ rightPath - case PathCodec.Segment(segment, _) => + case PathCodec.Segment(segment) => Right(segment.format(value.asInstanceOf[segment.Type])) case PathCodec.TransformOrFail(api, _, g) => @@ -264,16 +262,17 @@ sealed trait PathCodec[A] { self => private[http] def optimize: Array[Opt] = { def loop(pattern: PathCodec[_]): Chunk[Opt] = pattern match { - case PathCodec.Segment(segment, _) => + case PathCodec.Segment(segment) => Chunk(segment.asInstanceOf[SegmentCodec[_]] match { - case SegmentCodec.Empty(_) => Opt.Unit - case SegmentCodec.Literal(value, _) => Opt.Match(value) - case SegmentCodec.IntSeg(_, _) => Opt.IntOpt - case SegmentCodec.LongSeg(_, _) => Opt.LongOpt - case SegmentCodec.Text(_, _) => Opt.StringOpt - case SegmentCodec.UUID(_, _) => Opt.UUIDOpt - case SegmentCodec.BoolSeg(_, _) => Opt.BoolOpt - case SegmentCodec.Trailing(_) => Opt.TrailingOpt + case SegmentCodec.Empty => Opt.Unit + case SegmentCodec.Literal(value) => Opt.Match(value) + case SegmentCodec.IntSeg(_) => Opt.IntOpt + case SegmentCodec.LongSeg(_) => Opt.LongOpt + case SegmentCodec.Text(_) => Opt.StringOpt + case SegmentCodec.UUID(_) => Opt.UUIDOpt + case SegmentCodec.BoolSeg(_) => Opt.BoolOpt + case SegmentCodec.Trailing => Opt.TrailingOpt + case SegmentCodec.Annotated(codec, _) => loop(PathCodec.Segment(codec)).head }) case Concat(left, right, combiner, _) => @@ -296,7 +295,7 @@ sealed trait PathCodec[A] { self => case PathCodec.Concat(left, right, _, _) => loop(left) + loop(right) - case PathCodec.Segment(segment, _) => segment.render + case PathCodec.Segment(segment) => segment.render case PathCodec.TransformOrFail(api, _, _) => loop(api) @@ -305,12 +304,27 @@ sealed trait PathCodec[A] { self => loop(self) } + private[zio] def renderIgnoreTrailing: String = { + def loop(path: PathCodec[_]): String = path match { + case PathCodec.Concat(left, right, _, _) => + loop(left) + loop(right) + + case PathCodec.Segment(SegmentCodec.Trailing) => "" + + case PathCodec.Segment(segment) => segment.render + + case PathCodec.TransformOrFail(api, _, _) => loop(api) + } + + loop(self) + } + /** * Returns the segments of the path codec. */ def segments: Chunk[SegmentCodec[_]] = { def loop(path: PathCodec[_]): Chunk[SegmentCodec[_]] = path match { - case PathCodec.Segment(segment, _) => Chunk(segment) + case PathCodec.Segment(segment) => Chunk(segment) case PathCodec.Concat(left, right, _, _) => loop(left) ++ loop(right) @@ -354,7 +368,7 @@ object PathCodec { /** * The empty / root path codec. */ - def empty: PathCodec[Unit] = Segment[Unit](SegmentCodec.Empty()) + def empty: PathCodec[Unit] = Segment[Unit](SegmentCodec.Empty) def int(name: String): PathCodec[Int] = Segment(SegmentCodec.int(name)) @@ -366,12 +380,13 @@ object PathCodec { def string(name: String): PathCodec[String] = Segment(SegmentCodec.string(name)) - def trailing: PathCodec[Path] = Segment(SegmentCodec.Trailing()) + def trailing: PathCodec[Path] = Segment(SegmentCodec.Trailing) def uuid(name: String): PathCodec[java.util.UUID] = Segment(SegmentCodec.uuid(name)) - private[http] final case class Segment[A](segment: SegmentCodec[A], doc: Doc = Doc.empty) extends PathCodec[A] { - def ??(doc: Doc): Segment[A] = copy(doc = this.doc + doc) + private[http] final case class Segment[A](segment: SegmentCodec[A]) extends PathCodec[A] { + def ??(doc: Doc): Segment[A] = copy(segment ?? doc) + def doc: Doc = segment.doc } private[http] final case class Concat[A, B, C]( left: PathCodec[A], @@ -502,14 +517,14 @@ object PathCodec { .foldRight[SegmentSubtree[A]](SegmentSubtree(ListMap(), ListMap(), Chunk(value))) { case (segment, subtree) => val literals = segment match { - case SegmentCodec.Literal(value, _) => ListMap(value -> subtree) - case _ => ListMap.empty[String, SegmentSubtree[A]] + case SegmentCodec.Literal(value) => ListMap(value -> subtree) + case _ => ListMap.empty[String, SegmentSubtree[A]] } val others = ListMap[SegmentCodec[_], SegmentSubtree[A]]((segment match { - case SegmentCodec.Literal(_, _) => Chunk.empty - case _ => Chunk((segment, subtree)) + case SegmentCodec.Literal(_) => Chunk.empty + case _ => Chunk((segment, subtree)) }): _*) SegmentSubtree(literals, others, Chunk.empty) diff --git a/zio-http/src/main/scala/zio/http/codec/SegmentCodec.scala b/zio-http/src/main/scala/zio/http/codec/SegmentCodec.scala index 24137c0ced..ece07e6ba3 100644 --- a/zio-http/src/main/scala/zio/http/codec/SegmentCodec.scala +++ b/zio-http/src/main/scala/zio/http/codec/SegmentCodec.scala @@ -18,9 +18,9 @@ package zio.http.codec import scala.language.implicitConversions import zio.Chunk -import zio.stacktracer.TracingImplicits.disableAutoTrace import zio.http.Path +import zio.http.codec.SegmentCodec.{Annotated, MetaData} sealed trait SegmentCodec[A] { self => private var _hashCode: Int = 0 @@ -28,9 +28,24 @@ sealed trait SegmentCodec[A] { self => final type Type = A - def ??(doc: Doc): SegmentCodec[A] + def ??(doc: Doc): SegmentCodec[A] = + SegmentCodec.Annotated(self, Chunk(MetaData.Documented(doc))) + + def example(name: String, example: A): SegmentCodec[A] = + SegmentCodec.Annotated(self, Chunk(MetaData.Examples(Map(name -> example)))) + + def examples(examples: (String, A)*): SegmentCodec[A] = + SegmentCodec.Annotated(self, Chunk(MetaData.Examples(examples.toMap))) + + lazy val doc: Doc = self.asInstanceOf[SegmentCodec[_]] match { + case SegmentCodec.Annotated(_, annotations) => + annotations.collectFirst { case MetaData.Documented(doc) => doc }.getOrElse(Doc.Empty) + case _ => + Doc.Empty + } override def equals(that: Any): Boolean = that match { + case Annotated(codec, _) => codec == this case that: SegmentCodec[_] => (this.getClass == that.getClass) && (this.render == that.render) case _ => false } @@ -43,8 +58,8 @@ sealed trait SegmentCodec[A] { self => } final def isEmpty: Boolean = self.asInstanceOf[SegmentCodec[_]] match { - case SegmentCodec.Empty(_) => true - case _ => false + case SegmentCodec.Empty => true + case _ => false } // Returns number of segments matched, or -1 if not matched: @@ -54,14 +69,15 @@ sealed trait SegmentCodec[A] { self => final def render: String = { if (_render == "") _render = self.asInstanceOf[SegmentCodec[_]] match { - case SegmentCodec.Empty(_) => s"" - case SegmentCodec.Literal(value, _) => s"/$value" - case SegmentCodec.IntSeg(name, _) => s"/{$name}" - case SegmentCodec.LongSeg(name, _) => s"/{$name}" - case SegmentCodec.Text(name, _) => s"/{$name}" - case SegmentCodec.BoolSeg(name, _) => s"/{$name}" - case SegmentCodec.UUID(name, _) => s"/{$name}" - case SegmentCodec.Trailing(_) => s"/..." + case _: SegmentCodec.Empty.type => s"" + case SegmentCodec.Literal(value) => s"/$value" + case SegmentCodec.IntSeg(name) => s"/{$name}" + case SegmentCodec.LongSeg(name) => s"/{$name}" + case SegmentCodec.Text(name) => s"/{$name}" + case SegmentCodec.BoolSeg(name) => s"/{$name}" + case SegmentCodec.UUID(name) => s"/{$name}" + case _: SegmentCodec.Trailing.type => s"/..." + case SegmentCodec.Annotated(codec, _) => codec.render } _render } @@ -81,7 +97,7 @@ sealed trait SegmentCodec[A] { self => object SegmentCodec { def bool(name: String): SegmentCodec[Boolean] = SegmentCodec.BoolSeg(name) - val empty: SegmentCodec[Unit] = SegmentCodec.Empty() + val empty: SegmentCodec[Unit] = SegmentCodec.Empty def int(name: String): SegmentCodec[Int] = SegmentCodec.IntSeg(name) @@ -92,20 +108,43 @@ object SegmentCodec { def string(name: String): SegmentCodec[String] = SegmentCodec.Text(name) - def trailing: SegmentCodec[Path] = SegmentCodec.Trailing() + def trailing: SegmentCodec[Path] = SegmentCodec.Trailing def uuid(name: String): SegmentCodec[java.util.UUID] = SegmentCodec.UUID(name) - private[http] final case class Empty(doc: Doc = Doc.empty) extends SegmentCodec[Unit] { - def ??(doc: Doc): Empty = copy(doc = this.doc + doc) + final case class Annotated[A](codec: SegmentCodec[A], annotations: Chunk[MetaData[A]]) extends SegmentCodec[A] { + + override def equals(that: Any): Boolean = + codec.equals(that) + override def ??(doc: Doc): Annotated[A] = + copy(annotations = annotations :+ MetaData.Documented(doc)) + + override def example(name: String, example: A): Annotated[A] = + copy(annotations = annotations :+ MetaData.Examples(Map(name -> example))) + + override def examples(examples: (String, A)*): Annotated[A] = + copy(annotations = annotations :+ MetaData.Examples(examples.toMap)) + + def format(value: A): Path = codec.format(value) + + def matches(segments: Chunk[String], index: Int): Int = codec.matches(segments, index) + } + + sealed trait MetaData[A] extends Product with Serializable + + object MetaData { + final case class Documented[A](value: Doc) extends MetaData[A] + final case class Examples[A](examples: Map[String, A]) extends MetaData[A] + } + + private[http] case object Empty extends SegmentCodec[Unit] { self => def format(unit: Unit): Path = Path(s"") def matches(segments: Chunk[String], index: Int): Int = 0 } - private[http] final case class Literal(value: String, doc: Doc = Doc.empty) extends SegmentCodec[Unit] { - def ??(doc: Doc): Literal = copy(doc = this.doc + doc) + private[http] final case class Literal(value: String) extends SegmentCodec[Unit] { def format(unit: Unit): Path = Path(s"/$value") @@ -115,8 +154,7 @@ object SegmentCodec { else -1 } } - private[http] final case class BoolSeg(name: String, doc: Doc = Doc.empty) extends SegmentCodec[Boolean] { - def ??(doc: Doc): BoolSeg = copy(doc = this.doc + doc) + private[http] final case class BoolSeg(name: String) extends SegmentCodec[Boolean] { def format(value: Boolean): Path = Path(s"/$value") @@ -128,8 +166,7 @@ object SegmentCodec { if (segment == "true" || segment == "false") 1 else -1 } } - private[http] final case class IntSeg(name: String, doc: Doc = Doc.empty) extends SegmentCodec[Int] { - def ??(doc: Doc): IntSeg = copy(doc = this.doc + doc) + private[http] final case class IntSeg(name: String) extends SegmentCodec[Int] { def format(value: Int): Path = Path(s"/$value") @@ -151,8 +188,7 @@ object SegmentCodec { } } } - private[http] final case class LongSeg(name: String, doc: Doc = Doc.empty) extends SegmentCodec[Long] { - def ??(doc: Doc): LongSeg = copy(doc = this.doc + doc) + private[http] final case class LongSeg(name: String) extends SegmentCodec[Long] { def format(value: Long): Path = Path(s"/$value") @@ -174,8 +210,7 @@ object SegmentCodec { } } } - private[http] final case class Text(name: String, doc: Doc = Doc.empty) extends SegmentCodec[String] { - def ??(doc: Doc): Text = copy(doc = this.doc + doc) + private[http] final case class Text(name: String) extends SegmentCodec[String] { def format(value: String): Path = Path(s"/$value") @@ -183,8 +218,7 @@ object SegmentCodec { if (index < 0 || index >= segments.length) -1 else 1 } - private[http] final case class UUID(name: String, doc: Doc = Doc.empty) extends SegmentCodec[java.util.UUID] { - def ??(doc: Doc): UUID = copy(doc = this.doc + doc) + private[http] final case class UUID(name: String) extends SegmentCodec[java.util.UUID] { def format(value: java.util.UUID): Path = Path(s"/$value") @@ -221,9 +255,7 @@ object SegmentCodec { } } - final case class Trailing(doc: Doc = Doc.empty) extends SegmentCodec[Path] { self => - def ??(doc: Doc): SegmentCodec[Path] = copy(doc = this.doc + doc) - + case object Trailing extends SegmentCodec[Path] { self => def format(value: Path): Path = value def matches(segments: Chunk[String], index: Int): Int = diff --git a/zio-http/src/main/scala/zio/http/codec/internal/BodyCodec.scala b/zio-http/src/main/scala/zio/http/codec/internal/BodyCodec.scala index e60f55a60c..a03465f2f3 100644 --- a/zio-http/src/main/scala/zio/http/codec/internal/BodyCodec.scala +++ b/zio-http/src/main/scala/zio/http/codec/internal/BodyCodec.scala @@ -33,7 +33,7 @@ import zio.http.{Body, MediaType} * A BodyCodec encapsulates the logic necessary to both encode and decode bodies * for a media type, using ZIO Schema codecs and schemas. */ -private[internal] sealed trait BodyCodec[A] { self => +private[http] sealed trait BodyCodec[A] { self => /** * The element type, described by the schema. This could be the type of the @@ -88,7 +88,7 @@ private[internal] sealed trait BodyCodec[A] { self => */ def name: Option[String] } -private[internal] object BodyCodec { +private[http] object BodyCodec { case object Empty extends BodyCodec[Unit] { type Element = Unit diff --git a/zio-http/src/main/scala/zio/http/endpoint/Endpoint.scala b/zio-http/src/main/scala/zio/http/endpoint/Endpoint.scala index b1f6fcf3a4..cf84ad6f49 100644 --- a/zio-http/src/main/scala/zio/http/endpoint/Endpoint.scala +++ b/zio-http/src/main/scala/zio/http/endpoint/Endpoint.scala @@ -334,7 +334,7 @@ final case class Endpoint[PathInput, Input, Err, Output, Middleware <: EndpointM Endpoint( route, input, - output = (self.output | HttpCodec.content(implicitly[Schema[Output2]])) ++ StatusCodec.status(Status.Ok), + output = self.output | (HttpCodec.content(implicitly[Schema[Output2]]) ++ StatusCodec.status(Status.Ok)), error, doc, mw, @@ -387,7 +387,7 @@ final case class Endpoint[PathInput, Input, Err, Output, Middleware <: EndpointM input, output = self.output | ((HttpCodec.content(implicitly[Schema[Output2]]) ++ StatusCodec.status(status)) ?? doc), error, - doc, + Doc.empty, mw, ) @@ -402,7 +402,7 @@ final case class Endpoint[PathInput, Input, Err, Output, Middleware <: EndpointM Endpoint( route, input, - output = self.output | (HttpCodec.content(mediaType)(implicitly[Schema[Output2]]) ?? doc), + output = self.output | (HttpCodec.content(mediaType)(implicitly[Schema[Output2]]) ++ StatusCodec.Ok ?? doc), error, doc, mw, diff --git a/zio-http/src/main/scala/zio/http/endpoint/openapi/JsonRenderer.scala b/zio-http/src/main/scala/zio/http/endpoint/openapi/JsonRenderer.scala deleted file mode 100644 index 194c768f39..0000000000 --- a/zio-http/src/main/scala/zio/http/endpoint/openapi/JsonRenderer.scala +++ /dev/null @@ -1,143 +0,0 @@ -/* - * Copyright 2021 - 2023 Sporta Technologies PVT LTD & the ZIO HTTP contributors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package zio.http.endpoint.openapi - -import zio.NonEmptyChunk -import zio.http.codec.Doc -import zio.http.endpoint.openapi.OpenAPI.LiteralOrExpression -import zio.http.Status -import zio.stacktracer.TracingImplicits.disableAutoTrace - -import java.net.URI -import java.util.Base64 -import scala.language.implicitConversions // scalafix:ok; - -private[openapi] object JsonRenderer { - sealed trait Renderable[A] { - def render(a: A): String - } - - implicit class Renderer[T](t: T)(implicit val renderable: Renderable[T]) { - def render: String = renderable.render(t) - - val skip: Boolean = - t.asInstanceOf[Any] match { - case None => true - case _ => false - } - } - - def renderFields(fieldsIt: (String, Renderer[_])*): String = { - if (fieldsIt.map(_._1).toSet.size != fieldsIt.size) { - throw new IllegalArgumentException("Duplicate field names") - } else { - val fields = fieldsIt - .filterNot(_._2.skip) - .map { case (name, value) => s""""$name":${value.render}""" } - s"{${fields.mkString(",")}}" - } - } - - private def renderKey[K](k: K)(implicit renderable: Renderable[K]) = - if (renderable.render(k).startsWith("\"") && renderable.render(k).endsWith("\"")) renderable.render(k) - else s""""${renderable.render(k)}"""" - - implicit def stringRenderable[T <: String]: Renderable[T] = new Renderable[T] { - def render(a: T): String = s""""$a"""" - } - - implicit def intRenderable[T <: Int]: Renderable[T] = new Renderable[T] { - def render(a: T): String = a.toString - } - - implicit def Renderable[T <: Long]: Renderable[T] = new Renderable[T] { - def render(a: T): String = a.toString - } - - implicit def floatRenderable[T <: Float]: Renderable[T] = new Renderable[T] { - def render(a: T): String = a.toString - } - - implicit def doubleRenderable[T <: Double]: Renderable[T] = new Renderable[T] { - def render(a: T): String = a.toString - } - - implicit def booleanRenderable[T <: Boolean]: Renderable[T] = new Renderable[T] { - def render(a: T): String = a.toString - } - - implicit val uriRenderable: Renderable[URI] = new Renderable[URI] { - def render(a: URI): String = s""""${a.toString}"""" - } - - implicit val statusRenderable: Renderable[Status] = new Renderable[Status] { - def render(a: Status): String = a.code.toString - } - - implicit val docRenderable: Renderable[Doc] = new Renderable[Doc] { - def render(a: Doc): String = s""""${Base64.getEncoder.encodeToString(a.toCommonMark.getBytes)}"""" - } - - implicit def openapiBaseRenderable[T <: OpenAPIBase]: Renderable[T] = new Renderable[T] { - def render(a: T): String = a.toJson - } - - implicit def optionRenderable[A](implicit renderable: Renderable[A]): Renderable[Option[A]] = - new Renderable[Option[A]] { - def render(a: Option[A]): String = a match { - case Some(value) => renderable.render(value) - case None => "null" - } - } - - implicit def nonEmptyChunkRenderable[A](implicit renderable: Renderable[A]): Renderable[NonEmptyChunk[A]] = - new Renderable[NonEmptyChunk[A]] { - def render(a: NonEmptyChunk[A]): String = s"[${a.map(renderable.render).mkString(",")}]" - } - - implicit def setRenderable[A](implicit renderable: Renderable[A]): Renderable[Set[A]] = - new Renderable[Set[A]] { - def render(a: Set[A]): String = s"[${a.map(renderable.render).mkString(",")}]" - } - - implicit def listRenderable[A](implicit renderable: Renderable[A]): Renderable[List[A]] = - new Renderable[List[A]] { - def render(a: List[A]): String = s"[${a.map(renderable.render).mkString(",")}]" - } - - implicit def mapRenderable[K, V](implicit rK: Renderable[K], rV: Renderable[V]): Renderable[Map[K, V]] = - new Renderable[Map[K, V]] { - def render(a: Map[K, V]): String = - s"{${a.map { case (k, v) => s"${renderKey(k)}:${rV.render(v)}" }.mkString(",")}}" - } - - implicit def tupleRenderable[A, B](implicit rA: Renderable[A], rB: Renderable[B]): Renderable[(A, B)] = - new Renderable[(A, B)] { - def render(a: (A, B)): String = s"{${renderKey(a._1)}:${rB.render(a._2)}}" - } - - implicit def literalOrExpressionRenderable: Renderable[LiteralOrExpression] = - new Renderable[LiteralOrExpression] { - def render(a: LiteralOrExpression): String = a match { - case LiteralOrExpression.NumberLiteral(value) => implicitly[Renderable[Long]].render(value) - case LiteralOrExpression.DecimalLiteral(value) => implicitly[Renderable[Double]].render(value) - case LiteralOrExpression.StringLiteral(value) => implicitly[Renderable[String]].render(value) - case LiteralOrExpression.BooleanLiteral(value) => implicitly[Renderable[Boolean]].render(value) - case LiteralOrExpression.Expression(value) => implicitly[Renderable[String]].render(value) - } - } -} diff --git a/zio-http/src/main/scala/zio/http/endpoint/openapi/JsonSchema.scala b/zio-http/src/main/scala/zio/http/endpoint/openapi/JsonSchema.scala new file mode 100644 index 0000000000..c7b0b5b201 --- /dev/null +++ b/zio-http/src/main/scala/zio/http/endpoint/openapi/JsonSchema.scala @@ -0,0 +1,910 @@ +package zio.http.endpoint.openapi + +import zio._ +import zio.json.ast.Json + +import zio.schema.Schema.CaseClass0 +import zio.schema._ +import zio.schema.annotation._ +import zio.schema.codec._ +import zio.schema.codec.json._ + +import zio.http.codec.{SegmentCodec, TextCodec} + +private[openapi] case class SerializableJsonSchema( + @fieldName("$ref") ref: Option[String] = None, + @fieldName("type") schemaType: Option[TypeOrTypes] = None, + format: Option[String] = None, + oneOf: Option[Chunk[SerializableJsonSchema]] = None, + allOf: Option[Chunk[SerializableJsonSchema]] = None, + anyOf: Option[Chunk[SerializableJsonSchema]] = None, + enumValues: Option[Chunk[Json]] = None, + properties: Option[Map[String, SerializableJsonSchema]] = None, + additionalProperties: Option[BoolOrSchema] = None, + required: Option[Chunk[String]] = None, + items: Option[SerializableJsonSchema] = None, + nullable: Option[Boolean] = None, + description: Option[String] = None, + example: Option[Json] = None, + examples: Option[Chunk[Json]] = None, + discriminator: Option[OpenAPI.Discriminator] = None, + deprecated: Option[Boolean] = None, + contentEncoding: Option[String] = None, + contentMediaType: Option[String] = None, + default: Option[Json] = None, +) { + def asNullableType(nullable: Boolean): SerializableJsonSchema = + if (nullable && schemaType.isDefined) + copy(schemaType = Some(schemaType.get.add("null"))) + else if (nullable && oneOf.isDefined) + copy(oneOf = Some(oneOf.get :+ SerializableJsonSchema(schemaType = Some(TypeOrTypes.Type("null"))))) + else if (nullable && allOf.isDefined) + SerializableJsonSchema(oneOf = + Some(Chunk(this, SerializableJsonSchema(schemaType = Some(TypeOrTypes.Type("null"))))), + ) + else if (nullable && anyOf.isDefined) + copy(anyOf = Some(anyOf.get :+ SerializableJsonSchema(schemaType = Some(TypeOrTypes.Type("null"))))) + else + this + +} + +private[openapi] object SerializableJsonSchema { + implicit val schema: Schema[SerializableJsonSchema] = DeriveSchema.gen[SerializableJsonSchema] + + val binaryCodec: BinaryCodec[SerializableJsonSchema] = + JsonCodec.schemaBasedBinaryCodec[SerializableJsonSchema](JsonCodec.Config(ignoreEmptyCollections = true))( + Schema[SerializableJsonSchema], + ) +} + +@noDiscriminator +private[openapi] sealed trait BoolOrSchema + +private[openapi] object BoolOrSchema { + implicit val schema: Schema[BoolOrSchema] = DeriveSchema.gen[BoolOrSchema] + + final case class SchemaWrapper(schema: SerializableJsonSchema) extends BoolOrSchema + + object SchemaWrapper { + implicit val schema: Schema[SchemaWrapper] = + Schema[SerializableJsonSchema].transform(SchemaWrapper(_), _.schema) + } + + final case class BooleanWrapper(value: Boolean) extends BoolOrSchema + + object BooleanWrapper { + implicit val schema: Schema[BooleanWrapper] = + Schema[Boolean].transform[BooleanWrapper](b => BooleanWrapper(b), _.value) + } +} + +@noDiscriminator +private[openapi] sealed trait TypeOrTypes { self => + def add(value: String): TypeOrTypes = + self match { + case TypeOrTypes.Type(string) => TypeOrTypes.Types(Chunk(string, value)) + case TypeOrTypes.Types(chunk) => TypeOrTypes.Types(chunk :+ value) + } +} + +private[openapi] object TypeOrTypes { + implicit val schema: Schema[TypeOrTypes] = DeriveSchema.gen[TypeOrTypes] + + final case class Type(value: String) extends TypeOrTypes + + object Type { + implicit val schema: Schema[Type] = + Schema[String].transform[Type](s => Type(s), _.value) + } + + final case class Types(value: Chunk[String]) extends TypeOrTypes + + object Types { + implicit val schema: Schema[Types] = + Schema.chunk[String].transform[Types](s => Types(s), _.value) + } +} + +final case class JsonSchemas( + root: JsonSchema, + rootRef: Option[String], + children: Map[String, JsonSchema], +) + +sealed trait JsonSchema extends Product with Serializable { self => + + lazy val toJsonBytes: Chunk[Byte] = JsonCodec.schemaBasedBinaryCodec[JsonSchema].encode(self) + + lazy val toJson: String = toJsonBytes.asString + + protected[openapi] def toSerializableSchema: SerializableJsonSchema + def annotate(annotations: Chunk[JsonSchema.MetaData]): JsonSchema = + annotations.foldLeft(self) { case (schema, annotation) => schema.annotate(annotation) } + def annotate(annotation: JsonSchema.MetaData): JsonSchema = + JsonSchema.AnnotatedSchema(self, annotation) + + def annotations: Chunk[JsonSchema.MetaData] = self match { + case JsonSchema.AnnotatedSchema(schema, annotation) => schema.annotations :+ annotation + case _ => Chunk.empty + } + + def withoutAnnotations: JsonSchema = self match { + case JsonSchema.AnnotatedSchema(schema, _) => schema.withoutAnnotations + case _ => self + } + + def examples(examples: Chunk[Json]): JsonSchema = + JsonSchema.AnnotatedSchema(self, JsonSchema.MetaData.Examples(examples)) + + def default(default: Option[Json]): JsonSchema = + default match { + case Some(value) => JsonSchema.AnnotatedSchema(self, JsonSchema.MetaData.Default(value)) + case None => self + } + + def default(default: Json): JsonSchema = + JsonSchema.AnnotatedSchema(self, JsonSchema.MetaData.Default(default)) + + def description(description: String): JsonSchema = + JsonSchema.AnnotatedSchema(self, JsonSchema.MetaData.Description(description)) + + def description(description: Option[String]): JsonSchema = + description match { + case Some(value) => JsonSchema.AnnotatedSchema(self, JsonSchema.MetaData.Description(value)) + case None => self + } + + def description: Option[String] = self.toSerializableSchema.description + + def nullable(nullable: Boolean): JsonSchema = + JsonSchema.AnnotatedSchema(self, JsonSchema.MetaData.Nullable(nullable)) + + def discriminator(discriminator: OpenAPI.Discriminator): JsonSchema = + JsonSchema.AnnotatedSchema(self, JsonSchema.MetaData.Discriminator(discriminator)) + + def discriminator(discriminator: Option[OpenAPI.Discriminator]): JsonSchema = + discriminator match { + case Some(discriminator) => + JsonSchema.AnnotatedSchema(self, JsonSchema.MetaData.Discriminator(discriminator)) + case None => + self + } + + def deprecated(deprecated: Boolean): JsonSchema = + if (deprecated) JsonSchema.AnnotatedSchema(self, JsonSchema.MetaData.Deprecated) + else self + + def contentEncoding(encoding: JsonSchema.ContentEncoding): JsonSchema = + JsonSchema.AnnotatedSchema(self, JsonSchema.MetaData.ContentEncoding(encoding)) + + def contentMediaType(mediaType: String): JsonSchema = + JsonSchema.AnnotatedSchema(self, JsonSchema.MetaData.ContentMediaType(mediaType)) + +} + +object JsonSchema { + + implicit val schema: Schema[JsonSchema] = + SerializableJsonSchema.schema.transform[JsonSchema](JsonSchema.fromSerializableSchema, _.toSerializableSchema) + + private[openapi] val codec = JsonCodec.schemaBasedBinaryCodec[JsonSchema] + + private def toJsonAst(schema: Schema[_], v: Any): Json = + JsonCodec + .jsonEncoder(schema.asInstanceOf[Schema[Any]]) + .toJsonAST(v) + .toOption + .get + + private def fromSerializableSchema(schema: SerializableJsonSchema): JsonSchema = { + val additionalProperties = schema.additionalProperties match { + case Some(BoolOrSchema.BooleanWrapper(false)) => Left(false) + case Some(BoolOrSchema.BooleanWrapper(true)) => Left(true) + case Some(BoolOrSchema.SchemaWrapper(schema)) => Right(fromSerializableSchema(schema)) + case None => Left(true) + } + + var jsonSchema = schema match { + case schema if schema.ref.isDefined => + RefSchema(schema.ref.get) + case schema if schema.schemaType.contains(TypeOrTypes.Type("number")) => + JsonSchema.Number(NumberFormat.fromString(schema.format.getOrElse("double"))) + case schema if schema.schemaType.contains(TypeOrTypes.Type("integer")) => + JsonSchema.Integer(IntegerFormat.fromString(schema.format.getOrElse("int64"))) + case schema if schema.schemaType.contains(TypeOrTypes.Type("string")) && schema.enumValues.isEmpty => + JsonSchema.String + case schema if schema.schemaType.contains(TypeOrTypes.Type("boolean")) => + JsonSchema.Boolean + case schema if schema.schemaType.contains(TypeOrTypes.Type("array")) => + JsonSchema.ArrayType(schema.items.map(fromSerializableSchema)) + case schema if schema.schemaType.contains(TypeOrTypes.Type("object")) || schema.schemaType.isEmpty => + JsonSchema.Object( + schema.properties + .map(_.map { case (name, schema) => name -> fromSerializableSchema(schema) }) + .getOrElse(Map.empty), + additionalProperties, + schema.required.getOrElse(Chunk.empty), + ) + case schema if schema.enumValues.isDefined => + JsonSchema.Enum(schema.enumValues.get.map(EnumValue.fromJson)) + case schema if schema.oneOf.isDefined => + OneOfSchema(schema.oneOf.get.map(fromSerializableSchema)) + case schema if schema.allOf.isDefined => + AllOfSchema(schema.allOf.get.map(fromSerializableSchema)) + case schema if schema.anyOf.isDefined => + AnyOfSchema(schema.anyOf.get.map(fromSerializableSchema)) + case schema if schema.schemaType.contains(TypeOrTypes.Type("null")) => + JsonSchema.Null + case _ => + throw new IllegalArgumentException(s"Can't convert $schema") + } + + val examples = Chunk.fromIterable(schema.example) ++ schema.examples.getOrElse(Chunk.empty) + if (examples.nonEmpty) jsonSchema = jsonSchema.examples(examples) + + schema.description match { + case Some(value) => jsonSchema = jsonSchema.description(value) + case None => () + } + + schema.nullable match { + case Some(value) => jsonSchema = jsonSchema.nullable(value) + case None => () + } + + schema.discriminator match { + case Some(value) => jsonSchema = jsonSchema.discriminator(value) + case None => () + } + + schema.contentEncoding.flatMap(ContentEncoding.fromString) match { + case Some(value) => jsonSchema = jsonSchema.contentEncoding(value) + case None => () + } + + schema.contentMediaType match { + case Some(value) => jsonSchema = jsonSchema.contentMediaType(value) + case None => () + } + + jsonSchema = jsonSchema.default(schema.default) + + jsonSchema = jsonSchema.deprecated(schema.deprecated.getOrElse(false)) + + jsonSchema + } + + def fromTextCodec(codec: TextCodec[_]): JsonSchema = + codec match { + case TextCodec.Constant(string) => JsonSchema.Enum(Chunk(EnumValue.Str(string))) + case TextCodec.StringCodec => JsonSchema.String + case TextCodec.IntCodec => JsonSchema.Integer(JsonSchema.IntegerFormat.Int32) + case TextCodec.LongCodec => JsonSchema.Integer(JsonSchema.IntegerFormat.Int64) + case TextCodec.BooleanCodec => JsonSchema.Boolean + case TextCodec.UUIDCodec => JsonSchema.String + } + + def fromSegmentCodec(codec: SegmentCodec[_]): JsonSchema = + codec match { + case SegmentCodec.BoolSeg(_) => JsonSchema.Boolean + case SegmentCodec.IntSeg(_) => JsonSchema.Integer(JsonSchema.IntegerFormat.Int32) + case SegmentCodec.LongSeg(_) => JsonSchema.Integer(JsonSchema.IntegerFormat.Int64) + case SegmentCodec.Text(_) => JsonSchema.String + case SegmentCodec.UUID(_) => JsonSchema.String + case SegmentCodec.Annotated(codec, annotations) => + fromSegmentCodec(codec).description(segmentDoc(annotations)).examples(segmentExamples(codec, annotations)) + case SegmentCodec.Literal(_) => throw new IllegalArgumentException("Literal segment is not supported.") + case SegmentCodec.Empty => throw new IllegalArgumentException("Empty segment is not supported.") + case SegmentCodec.Trailing => throw new IllegalArgumentException("Trailing segment is not supported.") + } + + private def segmentDoc(annotations: Chunk[SegmentCodec.MetaData[_]]) = + annotations.collect { case SegmentCodec.MetaData.Documented(doc) => doc }.reduceOption(_ + _).map(_.toCommonMark) + + private def segmentExamples(codec: SegmentCodec[_], annotations: Chunk[SegmentCodec.MetaData[_]]) = + Chunk.fromIterable( + annotations.collect { case SegmentCodec.MetaData.Examples(example) => example.values }.flatten.map { value => + codec match { + case SegmentCodec.Empty => throw new IllegalArgumentException("Empty segment is not supported.") + case SegmentCodec.Literal(_) => throw new IllegalArgumentException("Literal segment is not supported.") + case SegmentCodec.BoolSeg(_) => Json.Bool(value.asInstanceOf[Boolean]) + case SegmentCodec.IntSeg(_) => Json.Num(value.asInstanceOf[Int]) + case SegmentCodec.LongSeg(_) => Json.Num(value.asInstanceOf[Long]) + case SegmentCodec.Text(_) => Json.Str(value.asInstanceOf[String]) + case SegmentCodec.UUID(_) => Json.Str(value.asInstanceOf[java.util.UUID].toString) + case SegmentCodec.Trailing => + throw new IllegalArgumentException("Trailing segment is not supported.") + case SegmentCodec.Annotated(_, _) => + throw new IllegalStateException("Annotated SegmentCodec should never be nested.") + } + }, + ) + + def fromZSchemaMulti(schema: Schema[_], refType: SchemaStyle = SchemaStyle.Inline): JsonSchemas = { + val ref = nominal(schema, refType) + schema match { + case enum0: Schema.Enum[_] if enum0.cases.forall(_.schema.isInstanceOf[CaseClass0[_]]) => + JsonSchemas(fromZSchema(enum0, SchemaStyle.Inline), ref, Map.empty) + case enum0: Schema.Enum[_] => + JsonSchemas( + fromZSchema(enum0, SchemaStyle.Inline), + ref, + enum0.cases + .filterNot(_.annotations.exists(_.isInstanceOf[transientCase])) + .flatMap { c => + val key = + nominal(c.schema, refType) + .orElse(nominal(c.schema, SchemaStyle.Compact)) + .getOrElse(throw new Exception(s"Unsupported enum case schema: ${c.schema}")) + val nested = fromZSchemaMulti( + c.schema, + refType, + ) + nested.children + (key -> nested.root) + } + .toMap, + ) + case record: Schema.Record[_] => + val children = record.fields + .filterNot(_.annotations.exists(_.isInstanceOf[transientField])) + .flatMap { field => + val key = nominal(field.schema, refType).orElse(nominal(field.schema, SchemaStyle.Compact)) + val nested = fromZSchemaMulti( + field.schema, + refType, + ) + key.map(k => nested.children + (k -> nested.root)).getOrElse(nested.children) + } + .toMap + JsonSchemas(fromZSchema(record, SchemaStyle.Inline), ref, children) + case collection: Schema.Collection[_, _] => + collection match { + case Schema.Sequence(elementSchema, _, _, _, _) => + arraySchemaMulti(refType, ref, elementSchema) + case Schema.Map(_, valueSchema, _) => + val nested = fromZSchemaMulti(valueSchema, refType) + if (valueSchema.isInstanceOf[Schema.Primitive[_]]) { + JsonSchemas( + JsonSchema.Object( + Map.empty, + Right(nested.root), + Chunk.empty, + ), + ref, + nested.children, + ) + } else { + JsonSchemas( + JsonSchema.Object( + Map.empty, + Right(nested.root), + Chunk.empty, + ), + ref, + nested.children + (nested.rootRef.get -> nested.root), + ) + } + case Schema.Set(elementSchema, _) => + arraySchemaMulti(refType, ref, elementSchema) + } + case Schema.Transform(schema, _, _, _, _) => + fromZSchemaMulti(schema, refType) + case Schema.Primitive(_, _) => + JsonSchemas(fromZSchema(schema, SchemaStyle.Inline), ref, Map.empty) + case Schema.Optional(schema, _) => + fromZSchemaMulti(schema, refType) + case Schema.Fail(_, _) => + throw new IllegalArgumentException("Fail schema is not supported.") + case Schema.Tuple2(left, right, _) => + val leftSchema = fromZSchemaMulti(left, refType) + val rightSchema = fromZSchemaMulti(right, refType) + JsonSchemas( + AllOfSchema(Chunk(leftSchema.root, rightSchema.root)), + ref, + leftSchema.children ++ rightSchema.children, + ) + case Schema.Either(left, right, _) => + val leftSchema = fromZSchemaMulti(left, refType) + val rightSchema = fromZSchemaMulti(right, refType) + JsonSchemas( + OneOfSchema(Chunk(leftSchema.root, rightSchema.root)), + ref, + leftSchema.children ++ rightSchema.children, + ) + case Schema.Lazy(schema0) => + fromZSchemaMulti(schema0(), refType) + case Schema.Dynamic(_) => + throw new IllegalArgumentException("Dynamic schema is not supported.") + } + } + + private def arraySchemaMulti( + refType: SchemaStyle, + ref: Option[String], + elementSchema: Schema[_], + ): JsonSchemas = { + val nested = fromZSchemaMulti(elementSchema, refType) + if (elementSchema.isInstanceOf[Schema.Primitive[_]]) { + JsonSchemas( + JsonSchema.ArrayType(Some(nested.root)), + ref, + nested.children, + ) + } else { + JsonSchemas( + JsonSchema.ArrayType(Some(nested.root)), + ref, + nested.children + (nested.rootRef.get -> nested.root), + ) + } + } + + def fromZSchema(schema: Schema[_], refType: SchemaStyle = SchemaStyle.Inline): JsonSchema = + schema match { + case enum0: Schema.Enum[_] if refType != SchemaStyle.Inline && nominal(enum0).isDefined => + JsonSchema.RefSchema(nominal(enum0, refType).get) + case enum0: Schema.Enum[_] if enum0.cases.forall(_.schema.isInstanceOf[CaseClass0[_]]) => + JsonSchema.Enum(enum0.cases.map(c => EnumValue.Str(c.id))) + case enum0: Schema.Enum[_] => + val noDiscriminator = enum0.annotations.exists(_.isInstanceOf[noDiscriminator]) + val discriminatorName0 = + enum0.annotations.collectFirst { case discriminatorName(name) => name } + val nonTransientCases = enum0.cases.filterNot(_.annotations.exists(_.isInstanceOf[transientCase])) + if (noDiscriminator) { + JsonSchema + .OneOfSchema(nonTransientCases.map(c => fromZSchema(c.schema, SchemaStyle.Compact))) + } else if (discriminatorName0.isDefined) { + JsonSchema + .OneOfSchema(nonTransientCases.map(c => fromZSchema(c.schema, SchemaStyle.Compact))) + .discriminator( + OpenAPI.Discriminator( + propertyName = discriminatorName0.get, + mapping = nonTransientCases.map { c => + val name = c.annotations.collectFirst { case caseName(name) => name }.getOrElse(c.id) + name -> nominal(c.schema, refType).orElse(nominal(c.schema, SchemaStyle.Compact)).get + }.toMap, + ), + ) + } else { + JsonSchema + .OneOfSchema(nonTransientCases.map { c => + val name = c.annotations.collectFirst { case caseName(name) => name }.getOrElse(c.id) + Object(Map(name -> fromZSchema(c.schema, SchemaStyle.Compact)), Left(false), Chunk(name)) + }) + } + case record: Schema.Record[_] if refType != SchemaStyle.Inline && nominal(record).isDefined => + JsonSchema.RefSchema(nominal(record, refType).get) + case record: Schema.Record[_] => + val additionalProperties = + if (record.annotations.exists(_.isInstanceOf[rejectExtraFields])) { + Left(false) + } else { + Left(true) + } + val nonTransientFields = + record.fields.filterNot(_.annotations.exists(_.isInstanceOf[transientField])) + JsonSchema + .Object( + Map.empty, + additionalProperties, + Chunk.empty, + ) + .addAll(nonTransientFields.map { field => + field.name -> + fromZSchema(field.schema, refType) + .deprecated(deprecated(field.schema)) + .description(fieldDoc(field)) + .default(fieldDefault(field)) + }) + .required( + nonTransientFields + .filterNot(_.schema.isInstanceOf[Schema.Optional[_]]) + .filterNot(_.annotations.exists(_.isInstanceOf[fieldDefaultValue[_]])) + .filterNot(_.annotations.exists(_.isInstanceOf[optionalField])) + .map(_.name), + ) + .deprecated(deprecated(record)) + case collection: Schema.Collection[_, _] => + collection match { + case Schema.Sequence(elementSchema, _, _, _, _) => + JsonSchema.ArrayType(Some(fromZSchema(elementSchema, refType))) + case Schema.Map(_, valueSchema, _) => + JsonSchema.Object( + Map.empty, + Right(fromZSchema(valueSchema, refType)), + Chunk.empty, + ) + case Schema.Set(elementSchema, _) => + JsonSchema.ArrayType(Some(fromZSchema(elementSchema, refType))) + } + case Schema.Transform(schema, _, _, _, _) => + fromZSchema(schema, refType) + case Schema.Primitive(standardType, _) => + standardType match { + case StandardType.UnitType => JsonSchema.Null + case StandardType.StringType => JsonSchema.String + case StandardType.BoolType => JsonSchema.Boolean + case StandardType.ByteType => JsonSchema.String + case StandardType.ShortType => JsonSchema.Integer(IntegerFormat.Int32) + case StandardType.IntType => JsonSchema.Integer(IntegerFormat.Int32) + case StandardType.LongType => JsonSchema.Integer(IntegerFormat.Int64) + case StandardType.FloatType => JsonSchema.Number(NumberFormat.Float) + case StandardType.DoubleType => JsonSchema.Number(NumberFormat.Double) + case StandardType.BinaryType => JsonSchema.String + case StandardType.CharType => JsonSchema.String + case StandardType.UUIDType => JsonSchema.String + case StandardType.BigDecimalType => JsonSchema.Number(NumberFormat.Double) // TODO: Is this correct? + case StandardType.BigIntegerType => JsonSchema.Integer(IntegerFormat.Int64) + case StandardType.DayOfWeekType => JsonSchema.String + case StandardType.MonthType => JsonSchema.String + case StandardType.MonthDayType => JsonSchema.String + case StandardType.PeriodType => JsonSchema.String + case StandardType.YearType => JsonSchema.String + case StandardType.YearMonthType => JsonSchema.String + case StandardType.ZoneIdType => JsonSchema.String + case StandardType.ZoneOffsetType => JsonSchema.String + case StandardType.DurationType => JsonSchema.String + case StandardType.InstantType => JsonSchema.String + case StandardType.LocalDateType => JsonSchema.String + case StandardType.LocalTimeType => JsonSchema.String + case StandardType.LocalDateTimeType => JsonSchema.String + case StandardType.OffsetTimeType => JsonSchema.String + case StandardType.OffsetDateTimeType => JsonSchema.String + case StandardType.ZonedDateTimeType => JsonSchema.String + } + + case Schema.Optional(schema, _) => fromZSchema(schema, refType).nullable(true) + case Schema.Fail(_, _) => throw new IllegalArgumentException("Fail schema is not supported.") + case Schema.Tuple2(left, right, _) => AllOfSchema(Chunk(fromZSchema(left, refType), fromZSchema(right, refType))) + case Schema.Either(left, right, _) => OneOfSchema(Chunk(fromZSchema(left, refType), fromZSchema(right, refType))) + case Schema.Lazy(schema0) => fromZSchema(schema0(), refType) + case Schema.Dynamic(_) => throw new IllegalArgumentException("Dynamic schema is not supported.") + } + + sealed trait SchemaStyle extends Product with Serializable + object SchemaStyle { + + /** Generates inline json schema */ + case object Inline extends SchemaStyle + + /** + * Generates references to json schemas under #/components/schemas/{schema} + * and uses the full package path to help to generate unique schema names. + * @see + * SchemaStyle.Compact for compact schema names. + */ + case object Reference extends SchemaStyle + + /** + * Generates references to json schemas under #/components/schemas/{schema} + * and uses the type name to help to generate schema names. + * @see + * SchemaStyle.Reference for full package path schema names to avoid name + * collisions. + */ + case object Compact extends SchemaStyle + } + + private def deprecated(schema: Schema[_]): Boolean = + schema.annotations.exists(_.isInstanceOf[scala.deprecated]) + + private def fieldDoc(schema: Schema.Field[_, _]): Option[String] = { + val description0 = schema.annotations.collectFirst { case description(value) => value } + val defaultValue = schema.annotations.collectFirst { case fieldDefaultValue(value) => value }.map { _ => + s"${if (description0.isDefined) "\n" else ""}If not set, this field defaults to the value of the default annotation." + } + Some(description0.getOrElse("") + defaultValue.getOrElse("")) + .filter(_.nonEmpty) + } + + private def fieldDefault(schema: Schema.Field[_, _]): Option[Json] = + schema.annotations.collectFirst { case fieldDefaultValue(value) => value } + .map(toJsonAst(schema.schema, _)) + + private def nominal(schema: Schema[_], referenceType: SchemaStyle = SchemaStyle.Reference): Option[String] = + schema match { + case enumSchema: Schema.Enum[_] => refForTypeId(enumSchema.id, referenceType) + case record: Schema.Record[_] => refForTypeId(record.id, referenceType) + case _ => None + } + + private def refForTypeId(id: TypeId, referenceType: SchemaStyle): Option[String] = + id match { + case nominal: TypeId.Nominal if referenceType == SchemaStyle.Reference => + Some(s"#/components/schemas/${nominal.fullyQualified.replace(".", "_")}") + case nominal: TypeId.Nominal if referenceType == SchemaStyle.Compact => + Some(s"#/components/schemas/${nominal.typeName}") + case _ => + None + } + + def obj(properties: (String, JsonSchema)*): JsonSchema = + JsonSchema.Object( + properties = properties.toMap, + additionalProperties = Left(false), + required = Chunk.fromIterable(properties.toMap.keys), + ) + + final case class AnnotatedSchema(schema: JsonSchema, annotation: MetaData) extends JsonSchema { + override protected[openapi] def toSerializableSchema: SerializableJsonSchema = { + annotation match { + case MetaData.Examples(chunk) => + schema.toSerializableSchema.copy(examples = Some(chunk)) + case MetaData.Discriminator(discriminator) => + schema.toSerializableSchema.copy(discriminator = Some(discriminator)) + case MetaData.Nullable(nullable) => + schema.toSerializableSchema.asNullableType(nullable) + case MetaData.Description(description) => + schema.toSerializableSchema.copy(description = Some(description)) + case MetaData.ContentEncoding(encoding) => + schema.toSerializableSchema.copy(contentEncoding = Some(encoding.productPrefix.toLowerCase)) + case MetaData.ContentMediaType(mediaType) => + schema.toSerializableSchema.copy(contentMediaType = Some(mediaType)) + case MetaData.Deprecated => + schema.toSerializableSchema.copy(deprecated = Some(true)) + case MetaData.Default(default) => + schema.toSerializableSchema.copy(default = Some(default)) + } + } + } + + sealed trait MetaData extends Product with Serializable + object MetaData { + final case class Examples(chunk: Chunk[Json]) extends MetaData + final case class Default(default: Json) extends MetaData + final case class Discriminator(discriminator: OpenAPI.Discriminator) extends MetaData + final case class Nullable(nullable: Boolean) extends MetaData + final case class Description(description: String) extends MetaData + final case class ContentEncoding(encoding: JsonSchema.ContentEncoding) extends MetaData + final case class ContentMediaType(mediaType: String) extends MetaData + case object Deprecated extends MetaData + } + + sealed trait ContentEncoding extends Product with Serializable + object ContentEncoding { + case object SevenBit extends ContentEncoding + case object EightBit extends ContentEncoding + case object Binary extends ContentEncoding + case object QuotedPrintable extends ContentEncoding + case object Base16 extends ContentEncoding + case object Base32 extends ContentEncoding + case object Base64 extends ContentEncoding + + def fromString(string: String): Option[ContentEncoding] = + string.toLowerCase match { + case "7bit" => Some(SevenBit) + case "8bit" => Some(EightBit) + case "binary" => Some(Binary) + case "quoted-print" => Some(QuotedPrintable) + case "base16" => Some(Base16) + case "base32" => Some(Base32) + case "base64" => Some(Base64) + case _ => None + } + } + + final case class RefSchema(ref: String) extends JsonSchema { + override protected[openapi] def toSerializableSchema: SerializableJsonSchema = + SerializableJsonSchema(ref = Some(ref)) + } + + final case class OneOfSchema(oneOf: Chunk[JsonSchema]) extends JsonSchema { + override protected[openapi] def toSerializableSchema: SerializableJsonSchema = + SerializableJsonSchema( + oneOf = Some(oneOf.map(_.toSerializableSchema)), + ) + } + + final case class AllOfSchema(allOf: Chunk[JsonSchema]) extends JsonSchema { + override protected[openapi] def toSerializableSchema: SerializableJsonSchema = + SerializableJsonSchema( + allOf = Some(allOf.map(_.toSerializableSchema)), + ) + } + + final case class AnyOfSchema(anyOf: Chunk[JsonSchema]) extends JsonSchema { + def minify: JsonSchema = { + val (objects, others) = anyOf.distinct.span(_.withoutAnnotations.isInstanceOf[JsonSchema.Object]) + val markedForRemoval = (for { + obj <- objects + otherObj <- objects + notNullableSchemas = obj.withoutAnnotations.asInstanceOf[JsonSchema.Object].properties.collect { + case (name, schema) + if !schema.annotations.exists { case MetaData.Nullable(nullable) => nullable; case _ => false } => + name -> schema + } + if notNullableSchemas == otherObj.withoutAnnotations.asInstanceOf[JsonSchema.Object].properties + } yield otherObj).distinct + + val minified = objects.filterNot(markedForRemoval.contains).map { obj => + val annotations = obj.annotations + val asObject = obj.withoutAnnotations.asInstanceOf[JsonSchema.Object] + val notNullableSchemas = asObject.properties.collect { + case (name, schema) + if !schema.annotations.exists { case MetaData.Nullable(nullable) => nullable; case _ => false } => + name -> schema + } + asObject.required(asObject.required.filter(notNullableSchemas.contains)).annotate(annotations) + } + val newAnyOf = minified ++ others + + if (newAnyOf.size == 1) newAnyOf.head else AnyOfSchema(newAnyOf) + } + + override protected[openapi] def toSerializableSchema: SerializableJsonSchema = + SerializableJsonSchema( + anyOf = Some(anyOf.map(_.toSerializableSchema)), + ) + } + + final case class Number(format: NumberFormat) extends JsonSchema { + override protected[openapi] def toSerializableSchema: SerializableJsonSchema = + SerializableJsonSchema( + schemaType = Some(TypeOrTypes.Type("number")), + format = Some(format.productPrefix.toLowerCase), + ) + } + + sealed trait NumberFormat extends Product with Serializable + object NumberFormat { + + def fromString(string: String): NumberFormat = + string match { + case "float" => Float + case "double" => Double + case _ => throw new IllegalArgumentException(s"Unknown number format: $string") + } + case object Float extends NumberFormat + case object Double extends NumberFormat + + } + + final case class Integer(format: IntegerFormat) extends JsonSchema { + override protected[openapi] def toSerializableSchema: SerializableJsonSchema = + SerializableJsonSchema( + schemaType = Some(TypeOrTypes.Type("integer")), + format = Some(format.productPrefix.toLowerCase), + ) + } + + sealed trait IntegerFormat extends Product with Serializable + object IntegerFormat { + + def fromString(string: String): IntegerFormat = + string match { + case "int32" => Int32 + case "int64" => Int64 + case "timestamp" => Timestamp + case _ => throw new IllegalArgumentException(s"Unknown integer format: $string") + } + case object Int32 extends IntegerFormat + case object Int64 extends IntegerFormat + case object Timestamp extends IntegerFormat + } + + // TODO: Add string formats and patterns + case object String extends JsonSchema { + override protected[openapi] def toSerializableSchema: SerializableJsonSchema = + SerializableJsonSchema(schemaType = Some(TypeOrTypes.Type("string"))) + } + + case object Boolean extends JsonSchema { + override protected[openapi] def toSerializableSchema: SerializableJsonSchema = + SerializableJsonSchema(schemaType = Some(TypeOrTypes.Type("boolean"))) + } + + final case class ArrayType(items: Option[JsonSchema]) extends JsonSchema { + override protected[openapi] def toSerializableSchema: SerializableJsonSchema = + SerializableJsonSchema( + schemaType = Some(TypeOrTypes.Type("array")), + items = items.map(_.toSerializableSchema), + ) + } + + final case class Object( + properties: Map[String, JsonSchema], + additionalProperties: Either[Boolean, JsonSchema], + required: Chunk[String], + ) extends JsonSchema { + def addAll(value: Chunk[(String, JsonSchema)]): Object = + value.foldLeft(this) { case (obj, (name, schema)) => + schema match { + case Object(properties, additionalProperties, required) => + obj.copy( + properties = obj.properties ++ properties, + additionalProperties = combineAdditionalProperties(obj.additionalProperties, additionalProperties), + required = obj.required ++ required, + ) + case schema => obj.copy(properties = obj.properties + (name -> schema)) + } + } + + def required(required: Chunk[String]): Object = + this.copy(required = required) + + private def combineAdditionalProperties( + left: Either[Boolean, JsonSchema], + right: Either[Boolean, JsonSchema], + ): Either[Boolean, JsonSchema] = + (left, right) match { + case (Left(false), _) => Left(false) + case (_, Left(_)) => left + case (Left(true), _) => right + case (Right(left), Right(right)) => + Right(AllOfSchema(Chunk(left, right))) + } + + override protected[openapi] def toSerializableSchema: SerializableJsonSchema = { + val additionalProperties = this.additionalProperties match { + case Left(true) => Some(BoolOrSchema.BooleanWrapper(true)) + case Left(false) => Some(BoolOrSchema.BooleanWrapper(false)) + case Right(schema) => Some(BoolOrSchema.SchemaWrapper(schema.toSerializableSchema)) + } + SerializableJsonSchema( + schemaType = Some(TypeOrTypes.Type("object")), + properties = Some(properties.map { case (name, schema) => name -> schema.toSerializableSchema }), + additionalProperties = additionalProperties, + required = if (required.isEmpty) None else Some(required), + ) + } + } + + object Object { + val empty: JsonSchema.Object = JsonSchema.Object(Map.empty, Left(true), Chunk.empty) + } + + final case class Enum(values: Chunk[EnumValue]) extends JsonSchema { + override protected[openapi] def toSerializableSchema: SerializableJsonSchema = + SerializableJsonSchema( + schemaType = Some(TypeOrTypes.Type("string")), + enumValues = Some(values.map(_.toJson)), + ) + } + + @noDiscriminator + sealed trait EnumValue { self => + def toJson: Json = self match { + case EnumValue.Bool(value) => Json.Bool(value) + case EnumValue.Str(value) => Json.Str(value) + case EnumValue.Num(value) => Json.Num(value) + case EnumValue.Null => Json.Null + case EnumValue.SchemaValue(value) => + Json.decoder + .decodeJson(value.toJson) + .getOrElse(throw new IllegalArgumentException(s"Can't convert $self")) + } + } + + object EnumValue { + + def fromJson(json: Json): EnumValue = + json match { + case Json.Str(value) => Str(value) + case Json.Num(value) => Num(value) + case Json.Bool(value) => Bool(value) + case Json.Null => Null + case other => + SchemaValue( + JsonSchema.codec + .decode(Chunk.fromArray(other.toString().getBytes)) + .getOrElse(throw new IllegalArgumentException(s"Can't convert $json")), + ) + } + + final case class SchemaValue(value: JsonSchema) extends EnumValue + final case class Bool(value: Boolean) extends EnumValue + final case class Str(value: String) extends EnumValue + final case class Num(value: BigDecimal) extends EnumValue + case object Null extends EnumValue + + } + + case object Null extends JsonSchema { + override protected[openapi] def toSerializableSchema: SerializableJsonSchema = + SerializableJsonSchema( + schemaType = Some(TypeOrTypes.Type("null")), + ) + } + +} diff --git a/zio-http/src/main/scala/zio/http/endpoint/openapi/OpenAPI.scala b/zio-http/src/main/scala/zio/http/endpoint/openapi/OpenAPI.scala index 213ca93d2f..0ccb39fdf4 100644 --- a/zio-http/src/main/scala/zio/http/endpoint/openapi/OpenAPI.scala +++ b/zio-http/src/main/scala/zio/http/endpoint/openapi/OpenAPI.scala @@ -20,78 +20,204 @@ import java.net.URI import scala.util.matching.Regex -import zio.NonEmptyChunk -import zio.stacktracer.TracingImplicits.disableAutoTrace +import zio.Chunk +import zio.json.ast._ + +import zio.schema._ +import zio.schema.annotation.{fieldName, noDiscriminator} +import zio.schema.codec.JsonCodec +import zio.schema.codec.json._ import zio.http.Status import zio.http.codec.Doc -import zio.http.endpoint.openapi -import zio.http.endpoint.openapi.JsonRenderer._ +import zio.http.endpoint.openapi.OpenAPI.SecurityScheme.SecurityRequirement -private[openapi] sealed trait OpenAPIBase { - self => - def toJson: String +/** + * This is the root document object of the OpenAPI document. + * + * @param openapi + * This string MUST be the semantic version number of the OpenAPI + * Specification version that the OpenAPI document uses. The openapi field + * SHOULD be used by tooling specifications and clients to interpret the + * OpenAPI document. This is not related to the API info.version string. + * @param info + * Provides metadata about the API. The metadata MAY be used by tooling as + * required. + * @param servers + * A List of Server Objects, which provide connectivity information to a + * target server. If the servers property is empty, the default value would be + * a Server Object with a url value of /. + * @param paths + * The available paths and operations for the API. + * @param components + * An element to hold various schemas for the specification. + * @param security + * A declaration of which security mechanisms can be used across the API. The + * list of values includes alternative security requirement objects that can + * be used. Only one of the security requirement objects need to be satisfied + * to authorize a request. Individual operations can override this definition. + * To make security optional, an empty security requirement ({}) can be + * included in the List. + * @param tags + * A list of tags used by the specification with additional metadata. The + * order of the tags can be used to reflect on their order by the parsing + * tools. Not all tags that are used by the Operation Object must be declared. + * The tags that are not declared MAY be organized randomly or based on the + * tools’ logic. Each tag name in the list MUST be unique. + * @param externalDocs + * Additional external documentation. + */ +final case class OpenAPI( + openapi: String, + info: OpenAPI.Info, + servers: List[OpenAPI.Server] = List.empty, + paths: Map[OpenAPI.Path, OpenAPI.PathItem] = Map.empty, + components: Option[OpenAPI.Components], + security: List[SecurityRequirement] = List.empty, + tags: List[OpenAPI.Tag] = List.empty, + externalDocs: Option[OpenAPI.ExternalDoc], +) { + def ++(other: OpenAPI): OpenAPI = OpenAPI( + openapi = openapi, + info = info, + servers = servers ++ other.servers, + paths = paths ++ other.paths, + components = (components.toSeq ++ other.components).reduceOption(_ ++ _), + security = security ++ other.security, + tags = tags ++ other.tags, + externalDocs = externalDocs, + ) + + def toJson: String = + JsonCodec + .jsonEncoder(JsonCodec.Config(ignoreEmptyCollections = true))(OpenAPI.schema) + .encodeJson(this, None) + .toString + + def toJsonPretty: String = + JsonCodec + .jsonEncoder(JsonCodec.Config(ignoreEmptyCollections = true))(OpenAPI.schema) + .encodeJson(this, Some(0)) + .toString + + def title(title: String): OpenAPI = copy(info = info.copy(title = title)) + + def version(version: String): OpenAPI = copy(info = info.copy(version = version)) } object OpenAPI { - /** - * This is the root document object of the OpenAPI document. - * - * @param openapi - * This string MUST be the semantic version number of the OpenAPI - * Specification version that the OpenAPI document uses. The openapi field - * SHOULD be used by tooling specifications and clients to interpret the - * OpenAPI document. This is not related to the API info.version string. - * @param info - * Provides metadata about the API. The metadata MAY be used by tooling as - * required. - * @param servers - * A List of Server Objects, which provide connectivity information to a - * target server. If the servers property is empty, the default value would - * be a Server Object with a url value of /. - * @param paths - * The available paths and operations for the API. - * @param components - * An element to hold various schemas for the specification. - * @param security - * A declaration of which security mechanisms can be used across the API. - * The list of values includes alternative security requirement objects that - * can be used. Only one of the security requirement objects need to be - * satisfied to authorize a request. Individual operations can override this - * definition. To make security optional, an empty security requirement ({}) - * can be included in the List. - * @param tags - * A list of tags used by the specification with additional metadata. The - * order of the tags can be used to reflect on their order by the parsing - * tools. Not all tags that are used by the Operation Object must be - * declared. The tags that are not declared MAY be organized randomly or - * based on the tools’ logic. Each tag name in the list MUST be unique. - * @param externalDocs - * Additional external documentation. - */ - final case class OpenAPI( - openapi: String, - info: Info, - servers: List[Server], - paths: Paths, - components: Option[Components], - security: List[SecurityRequirement], - tags: List[Tag], - externalDocs: Option[ExternalDoc], - ) extends OpenAPIBase { - def toJson: String = - JsonRenderer.renderFields( - "openapi" -> openapi, - "info" -> info, - "servers" -> servers, - "paths" -> paths, - "components" -> components, - "security" -> security, - "tags" -> tags, - "externalDocs" -> externalDocs, + implicit val schema: Schema[OpenAPI] = + DeriveSchema.gen[OpenAPI] + + def empty: OpenAPI = OpenAPI( + openapi = "3.1.0", + info = Info( + title = "", + description = None, + termsOfService = None, + contact = None, + license = None, + version = "", + ), + servers = List.empty, + paths = Map.empty, + components = None, + security = List.empty, + tags = List.empty, + externalDocs = None, + ) + + implicit def statusSchema: Schema[Status] = + zio.schema + .Schema[String] + .transformOrFail[Status]( + s => Status.fromInt(s.toInt).toRight("Invalid Status"), + p => Right(p.text), + ) + + implicit def pathMapSchema: Schema[Map[Path, PathItem]] = + DeriveSchema + .gen[Map[String, PathItem]] + .transformOrFail( + m => { + val it = m.iterator + var transformed = Map.empty[Path, PathItem] + var error: Left[String, Map[Path, PathItem]] = null + while (it.hasNext && error == null) { + val (k, v) = it.next() + Path.fromString(k) match { + case Some(path) => transformed += path -> v + case None => error = Left(s"Invalid path: $k") + } + } + if (error != null) error + else Right(transformed) + }, + (m: Map[Path, PathItem]) => Right(m.map { case (k, v) => k.name -> v }), + ) + + implicit def keyMapSchema[T](implicit + schema: Schema[T], + ): Schema[Map[Key, T]] = + Schema + .map[String, T] + .transformOrFail( + m => { + val it = m.iterator + var transformed = Map.empty[Key, T] + var error: Left[String, Map[Key, T]] = null + while (it.hasNext && error == null) { + val (k, v) = it.next() + Key.fromString(k) match { + case Some(key) => transformed += key -> v + case None => error = Left(s"Invalid key: $k") + } + } + if (error != null) error + else Right(transformed) + }, + (m: Map[Key, T]) => Right(m.map { case (k, v) => k.name -> v }), + ) + + implicit def statusMapSchema[T](implicit + schema: Schema[T], + ): Schema[Map[StatusOrDefault, T]] = + Schema + .map[String, T] + .transformOrFail( + m => { + val it = m.iterator + var transformed = Map.empty[StatusOrDefault, T] + var error: Left[String, Map[StatusOrDefault, T]] = null + while (it.hasNext && error == null) { + val (k, v) = it.next() + if (k == "default") transformed += StatusOrDefault.Default -> v + else { + zio.http.Status.fromString(k) match { + case Some(key) => transformed += StatusOrDefault.StatusValue(key) -> v + case None => error = Left(s"Invalid status: $k") + } + } + } + if (error != null) error + else Right(transformed) + }, + (m: Map[StatusOrDefault, T]) => Right(m.map { case (k, v) => k.text -> v }), + ) + + implicit def mediaTypeTupleSchema: Schema[(String, MediaType)] = + zio.schema + .Schema[Map[String, MediaType]] + .transformOrFail( + m => { + if (m.size == 1) { + val (k, v) = m.head + Right((k, v)) + } else Left("Invalid media type") + }, + t => Right(Map(t._1 -> t._2)), ) - } /** * Allows referencing an external resource for extended documentation. @@ -102,9 +228,7 @@ object OpenAPI { * @param url * The URL for the target documentation. */ - final case class ExternalDoc(description: Option[Doc], url: URI) extends openapi.OpenAPIBase { - override def toJson: String = JsonRenderer.renderFields("description" -> description, "url" -> url) - } + final case class ExternalDoc(description: Option[Doc], url: URI) /** * The object provides metadata about the API. The metadata MAY be used by the @@ -127,21 +251,12 @@ object OpenAPI { */ final case class Info( title: String, - description: Doc, - termsOfService: URI, + description: Option[Doc], + termsOfService: Option[URI], contact: Option[Contact], license: Option[License], version: String, - ) extends openapi.OpenAPIBase { - override def toJson: String = JsonRenderer.renderFields( - "title" -> title, - "description" -> description, - "termsOfService" -> termsOfService, - "contact" -> contact, - "license" -> license, - "version" -> version, - ) - } + ) /** * Contact information for the exposed API. @@ -154,9 +269,7 @@ object OpenAPI { * The email address of the contact person/organization. MUST be in the * format of an email address. */ - final case class Contact(name: Option[String], url: Option[URI], email: String) extends openapi.OpenAPIBase { - override def toJson: String = JsonRenderer.renderFields("name" -> name, "url" -> url, "email" -> email) - } + final case class Contact(name: Option[String], url: Option[URI], email: Option[String]) /** * License information for the exposed API. @@ -166,9 +279,7 @@ object OpenAPI { * @param url * A URL to the license used for the API. */ - final case class License(name: String, url: Option[URI]) extends openapi.OpenAPIBase { - override def toJson: String = JsonRenderer.renderFields("name" -> name, "url" -> url) - } + final case class License(name: String, url: Option[URI]) /** * An object representing a Server. @@ -184,14 +295,11 @@ object OpenAPI { * A map between a variable name and its value. The value is used for * substitution in the server’s URL template. */ - final case class Server(url: URI, description: Doc, variables: Map[String, ServerVariable]) - extends openapi.OpenAPIBase { - override def toJson: String = JsonRenderer.renderFields( - "url" -> url, - "description" -> description, - "variables" -> variables, - ) - } + final case class Server( + url: URI, + description: Option[Doc], + variables: Map[String, ServerVariable] = Map.empty, + ) /** * An object representing a Server Variable for server URL template @@ -209,13 +317,11 @@ object OpenAPI { * @param description * A description for the server variable. */ - final case class ServerVariable(`enum`: NonEmptyChunk[String], default: String, description: Doc) - extends openapi.OpenAPIBase { - override def toJson: String = JsonRenderer.renderFields( - "enum" -> `enum`, - "default" -> default, - "description" -> description, - ) + final case class ServerVariable(`enum`: Chunk[String], default: String, description: Doc) + + object ServerVariable { + implicit val schema: Schema[ServerVariable] = + DeriveSchema.gen[ServerVariable] } /** @@ -244,39 +350,45 @@ object OpenAPI { * An object to hold reusable Callback Objects. */ final case class Components( - schemas: Map[Key, SchemaOrReference], - responses: Map[Key, ResponseOrReference], - parameters: Map[Key, ParameterOrReference], - examples: Map[Key, ExampleOrReference], - requestBodies: Map[Key, RequestBodyOrReference], - headers: Map[Key, HeaderOrReference], - securitySchemes: Map[Key, SecuritySchemeOrReference], - links: Map[Key, LinkOrReference], - callbacks: Map[Key, CallbackOrReference], - ) extends openapi.OpenAPIBase { - override def toJson: String = JsonRenderer.renderFields( - "schemas" -> schemas, - "responses" -> responses, - "parameters" -> parameters, - "examples" -> examples, - "requestBodies" -> requestBodies, - "headers" -> headers, - "securitySchemes" -> securitySchemes, - "links" -> links, - "callbacks" -> callbacks, + schemas: Map[Key, ReferenceOr[JsonSchema]] = Map.empty, + responses: Map[Key, ReferenceOr[Response]] = Map.empty, + parameters: Map[Key, ReferenceOr[Parameter]] = Map.empty, + examples: Map[Key, ReferenceOr[Example]] = Map.empty, + requestBodies: Map[Key, ReferenceOr[RequestBody]] = Map.empty, + headers: Map[Key, ReferenceOr[Header]] = Map.empty, + securitySchemes: Map[Key, ReferenceOr[SecurityScheme]] = Map.empty, + links: Map[Key, ReferenceOr[Link]] = Map.empty, + callbacks: Map[Key, ReferenceOr[Callback]] = Map.empty, + ) { + def ++(other: Components): Components = Components( + schemas = schemas ++ other.schemas, + responses = responses ++ other.responses, + parameters = parameters ++ other.parameters, + examples = examples ++ other.examples, + requestBodies = requestBodies ++ other.requestBodies, + headers = headers ++ other.headers, + securitySchemes = securitySchemes ++ other.securitySchemes, + links = links ++ other.links, + callbacks = callbacks ++ other.callbacks, ) } - sealed abstract case class Key private (name: String) extends openapi.OpenAPIBase { - override def toJson: String = name - } + sealed abstract case class Key private (name: String) object Key { + implicit val schema: Schema[Key] = + zio.schema + .Schema[String] + .transformOrFail[Key]( + s => fromString(s).toRight(s"Invalid Key $s"), + p => Right(p.name), + ) + /** * All Components objects MUST use Keys that match the regular expression. */ - val validName: Regex = "^[a-zA-Z0-9.\\-_]+$.".r + val validName: Regex = "^[a-zA-Z0-9.\\-_]+$".r def fromString(name: String): Option[Key] = name match { case validName() => Some(new Key(name) {}) @@ -303,16 +415,19 @@ object OpenAPI { * @param name * The field name of the relative path MUST begin with a forward slash (/). */ - sealed abstract case class Path private (name: String) extends openapi.OpenAPIBase { - override def toJson: String = name - } + case class Path private (name: String) object Path { + implicit val schema: Schema[Path] = Schema[String].transformOrFail[Path]( + s => fromString(s).toRight(s"Invalid Path $s"), + p => Right(p.name), + ) + // todo maybe not the best regex, but the old one was not working at all - val validPath: Regex = "/[a-zA-Z0-9\\-_\\{\\}]+".r + val validPath: Regex = """/[/a-zA-Z0-9\-_{}]*""".r def fromString(name: String): Option[Path] = name match { - case validPath() => Some(new Path(name) {}) + case validPath() => Some(Path(name)) case _ => None } } @@ -359,9 +474,9 @@ object OpenAPI { * components/parameters. */ final case class PathItem( - ref: String, - summary: String = "", - description: Doc, + @fieldName("$ref") ref: Option[String], + summary: Option[String], + description: Option[Doc], get: Option[Operation], put: Option[Operation], post: Option[Operation], @@ -370,23 +485,48 @@ object OpenAPI { head: Option[Operation], patch: Option[Operation], trace: Option[Operation], - servers: List[Server], - parameters: Set[ParameterOrReference], - ) extends openapi.OpenAPIBase { - override def toJson: String = JsonRenderer.renderFields( - s"$$ref" -> ref, - "summary" -> summary, - "description" -> description, - "get" -> get, - "put" -> put, - "post" -> post, - "delete" -> delete, - "options" -> options, - "head" -> head, - "patch" -> patch, - "trace" -> trace, - "servers" -> servers, - "parameters" -> parameters, + servers: List[Server] = List.empty, + parameters: Set[ReferenceOr[Parameter]] = Set.empty, + ) { + def addGet(operation: Operation): PathItem = copy(get = Some(operation)) + def addPut(operation: Operation): PathItem = copy(put = Some(operation)) + def addPost(operation: Operation): PathItem = copy(post = Some(operation)) + def addDelete(operation: Operation): PathItem = copy(delete = Some(operation)) + def addOptions(operation: Operation): PathItem = copy(options = Some(operation)) + def addHead(operation: Operation): PathItem = copy(head = Some(operation)) + def addPatch(operation: Operation): PathItem = copy(patch = Some(operation)) + def addTrace(operation: Operation): PathItem = copy(trace = Some(operation)) + def any(operation: Operation): PathItem = + copy( + get = Some(operation), + put = Some(operation), + post = Some(operation), + delete = Some(operation), + options = Some(operation), + head = Some(operation), + patch = Some(operation), + trace = Some(operation), + ) + } + + object PathItem { + implicit val schema: Schema[PathItem] = + DeriveSchema.gen[PathItem] + + val empty: PathItem = PathItem( + ref = None, + summary = None, + description = None, + get = None, + put = None, + post = None, + delete = None, + options = None, + head = None, + patch = None, + trace = None, + servers = List.empty, + parameters = Set.empty, ) } @@ -445,98 +585,61 @@ object OpenAPI { * be overridden by this value. */ final case class Operation( - tags: List[String], - summary: String = "", - description: Doc, + tags: List[String] = List.empty, + summary: Option[String], + description: Option[Doc], externalDocs: Option[ExternalDoc], operationId: Option[String], - parameters: Set[ParameterOrReference], - requestBody: Option[RequestBodyOrReference], - responses: Responses, - callbacks: Map[String, CallbackOrReference], + parameters: Set[ReferenceOr[Parameter]] = Set.empty, + requestBody: Option[ReferenceOr[RequestBody]], + responses: Map[StatusOrDefault, ReferenceOr[Response]] = Map.empty, + callbacks: Map[String, ReferenceOr[Callback]] = Map.empty, deprecated: Boolean = false, - security: List[SecurityRequirement], - servers: List[Server], - ) extends openapi.OpenAPIBase { - override def toJson: String = JsonRenderer.renderFields( - "tags" -> tags, - "summary" -> summary, - "description" -> description, - "externalDocs" -> externalDocs, - "operationId" -> operationId, - "parameters" -> parameters, - "requestBody" -> requestBody, - "responses" -> responses, - "callbacks" -> callbacks, - "deprecated" -> deprecated, - "security" -> security, - "servers" -> servers, - ) - } - - sealed trait ParameterOrReference extends openapi.OpenAPIBase + security: List[SecurityRequirement] = List.empty, + servers: List[Server] = List.empty, + ) /** * Describes a single operation parameter. */ - sealed trait Parameter extends ParameterOrReference { - def name: String - def in: String - def description: Doc - def required: Boolean - def deprecated: Boolean - def allowEmptyValue: Boolean - def definition: Parameter.Definition - def explode: Boolean - def examples: Map[String, ExampleOrReference] - - /** - * A unique parameter is defined by a combination of a name and location. - */ + final case class Parameter( + name: String, + in: String, + description: Option[Doc], + required: Boolean = false, + deprecated: Boolean = false, + schema: Option[ReferenceOr[JsonSchema]], + explode: Boolean = false, + examples: Map[String, ReferenceOr[Example]] = Map.empty, + allowReserved: Option[Boolean], + style: Option[String], + content: Option[(String, MediaType)], + ) { override def equals(obj: Any): Boolean = obj match { - case p: Parameter.QueryParameter if name == p.name && in == p.in => true - case _ => false + case p: Parameter if name == p.name && in == p.in => true + case _ => false } - - override def toJson: String = - JsonRenderer.renderFields( - "name" -> name, - "in" -> in, - "description" -> description, - "required" -> required, - "deprecated" -> deprecated, - "allowEmptyValue" -> allowEmptyValue, - "definition" -> definition, - "explode" -> explode, - "examples" -> examples, - ) } object Parameter { - sealed trait Definition extends SchemaOrReference - object Definition { - final case class Content(key: String, mediaType: String) extends Definition { - override def toJson: String = JsonRenderer.renderFields( - "key" -> key, - "mediaType" -> mediaType, - ) - } - } + implicit val schema: Schema[Parameter] = + DeriveSchema.gen[Parameter] - sealed trait PathStyle + final case class Content(key: String, mediaType: MediaType) + sealed trait PathStyle sealed trait QueryStyle - object QueryStyle { + object Style { case object Matrix extends PathStyle case object Label extends PathStyle - case object Simple extends PathStyle - case object Form extends QueryStyle + case object Simple extends PathStyle + case object SpaceDelimited extends QueryStyle case object PipeDelimited extends QueryStyle @@ -555,27 +658,35 @@ object OpenAPI { * @param deprecated * Specifies that a parameter is deprecated and SHOULD be transitioned out * of usage. - * @param allowEmptyValue - * Sets the ability to pass empty-valued parameters. This is valid only - * for query parameters and allows sending a parameter with an empty - * value. If style is used, and if behavior is n/a (cannot be serialized), - * the value of allowEmptyValue SHALL be ignored. Use of this property is - * NOT RECOMMENDED, as it is likely to be removed in a later revision. */ - final case class QueryParameter( + def queryParameter( name: String, - description: Doc, + description: Option[Doc], + schema: Option[ReferenceOr[JsonSchema]], + examples: Map[String, ReferenceOr[Example]], deprecated: Boolean = false, - allowEmptyValue: Boolean = false, - definition: Definition, - allowReserved: Boolean = false, - style: QueryStyle = QueryStyle.Form, explode: Boolean = true, - examples: Map[String, ExampleOrReference], - ) extends Parameter { - def in: String = "query" - def required: Boolean = true - } + required: Boolean = false, + allowReserved: Boolean = false, + style: QueryStyle = Style.Form, + ): Parameter = Parameter( + name, + "query", + description, + required, + deprecated, + schema, + explode, + examples, + Some(allowReserved), + style = Some(style match { + case Style.Form => "form" + case Style.SpaceDelimited => "spaceDelimited" + case Style.PipeDelimited => "pipeDelimited" + case Style.DeepObject => "deepObject" + }), + None, + ) /** * Custom headers that are expected as part of the request. Note that @@ -590,26 +701,28 @@ object OpenAPI { * @param deprecated * Specifies that a parameter is deprecated and SHOULD be transitioned out * of usage. - * @param allowEmptyValue - * Sets the ability to pass empty-valued parameters. This is valid only - * for query parameters and allows sending a parameter with an empty - * value. If style is used, and if behavior is n/a (cannot be serialized), - * the value of allowEmptyValue SHALL be ignored. Use of this property is - * NOT RECOMMENDED, as it is likely to be removed in a later revision. */ - final case class HeaderParameter( + def headerParameter( name: String, - description: Doc, + description: Option[Doc], required: Boolean, deprecated: Boolean = false, - allowEmptyValue: Boolean = false, - definition: Definition, + definition: Option[ReferenceOr[JsonSchema]] = None, explode: Boolean = false, - examples: Map[String, ExampleOrReference], - ) extends Parameter { - def in: String = "header" - def style: String = "simple" - } + examples: Map[String, ReferenceOr[Example]], + ): Parameter = Parameter( + name, + "header", + description, + required, + deprecated, + definition, + explode, + examples, + allowReserved = None, + style = Some("simple"), + None, + ) /** * Used together with Path Templating, where the parameter value is actually @@ -621,31 +734,35 @@ object OpenAPI { * The name of the parameter. Parameter names are case sensitive. * @param description * A brief description of the parameter. - * @param required - * Determines whether this parameter is mandatory. * @param deprecated * Specifies that a parameter is deprecated and SHOULD be transitioned out * of usage. - * @param allowEmptyValue - * Sets the ability to pass empty-valued parameters. This is valid only - * for query parameters and allows sending a parameter with an empty - * value. If style is used, and if behavior is n/a (cannot be serialized), - * the value of allowEmptyValue SHALL be ignored. Use of this property is - * NOT RECOMMENDED, as it is likely to be removed in a later revision. */ - final case class PathParameter( + def pathParameter( name: String, - description: Doc, - required: Boolean, + description: Option[Doc], deprecated: Boolean = false, - allowEmptyValue: Boolean = false, - definition: Definition, - style: PathStyle = QueryStyle.Simple, + definition: Option[ReferenceOr[JsonSchema]] = None, + style: PathStyle = Style.Simple, explode: Boolean = false, - examples: Map[String, ExampleOrReference], - ) extends Parameter { - def in: String = "path" - } + examples: Map[String, ReferenceOr[Example]], + ): Parameter = Parameter( + name, + "path", + description, + required = true, + deprecated, + definition, + explode, + examples, + allowReserved = None, + style = Some(style match { + case Style.Matrix => "matrix" + case Style.Label => "label" + case Style.Simple => "simple" + }), + None, + ) /** * Used to pass a specific cookie value to the API. @@ -659,47 +776,42 @@ object OpenAPI { * @param deprecated * Specifies that a parameter is deprecated and SHOULD be transitioned out * of usage. - * @param allowEmptyValue - * Sets the ability to pass empty-valued parameters. This is valid only - * for query parameters and allows sending a parameter with an empty - * value. If style is used, and if behavior is n/a (cannot be serialized), - * the value of allowEmptyValue SHALL be ignored. Use of this property is - * NOT RECOMMENDED, as it is likely to be removed in a later revision. */ - final case class CookieParameter( + def cookieParameter( name: String, - description: Doc, + description: Option[Doc], required: Boolean, deprecated: Boolean = false, - allowEmptyValue: Boolean = false, - definition: Definition, + definition: Option[ReferenceOr[JsonSchema]] = None, explode: Boolean = false, - examples: Map[String, ExampleOrReference], - ) extends Parameter { - def in: String = "cookie" - def style: String = "form" - } + examples: Map[String, ReferenceOr[Example]], + ): Parameter = Parameter( + name, + "cookie", + description, + required, + deprecated, + definition, + explode, + examples, + allowReserved = None, + style = Some("form"), + None, + ) } - sealed trait HeaderOrReference extends openapi.OpenAPIBase - final case class Header( - description: Doc, - required: Boolean, - deprecate: Boolean = false, + description: Option[Doc], + required: Boolean = false, + deprecated: Boolean = false, allowEmptyValue: Boolean = false, - content: (String, MediaType), - ) extends HeaderOrReference { - override def toJson: String = JsonRenderer.renderFields( - "description" -> description, - "required" -> required, - "deprecated" -> deprecate, - "allowEmptyValue" -> allowEmptyValue, - "content" -> content, - ) - } + schema: Option[JsonSchema], + ) - sealed trait RequestBodyOrReference extends openapi.OpenAPIBase + object Header { + implicit val schema: Schema[Header] = + DeriveSchema.gen[Header] + } /** * Describes a single request body. @@ -714,13 +826,15 @@ object OpenAPI { * @param required * Determines if the request body is required in the request. */ - final case class RequestBody(description: Doc, content: Map[String, MediaType], required: Boolean = false) - extends ResponseOrReference { - override def toJson: String = JsonRenderer.renderFields( - "description" -> description, - "content" -> content, - "required" -> required, - ) + final case class RequestBody( + description: Option[Doc] = None, + content: Map[String, MediaType] = Map.empty, + required: Boolean = false, + ) + + object RequestBody { + implicit val schema: Schema[RequestBody] = + DeriveSchema.gen[RequestBody] } /** @@ -741,15 +855,14 @@ object OpenAPI { * type is multipart or application/x-www-form-urlencoded. */ final case class MediaType( - schema: SchemaOrReference, - examples: Map[String, ExampleOrReference], - encoding: Map[String, Encoding], - ) extends openapi.OpenAPIBase { - override def toJson: String = JsonRenderer.renderFields( - "schema" -> schema, - "examples" -> examples, - "encoding" -> encoding, - ) + schema: ReferenceOr[JsonSchema], + examples: Map[String, ReferenceOr[Example]] = Map.empty, + encoding: Map[String, Encoding] = Map.empty, + ) + + object MediaType { + implicit val schema: Schema[MediaType] = + DeriveSchema.gen[MediaType] } /** @@ -780,18 +893,15 @@ object OpenAPI { */ final case class Encoding( contentType: String, - headers: Map[String, HeaderOrReference], + headers: Map[String, ReferenceOr[Header]] = Map.empty, style: String = "form", explode: Boolean, allowReserved: Boolean = false, - ) extends openapi.OpenAPIBase { - override def toJson: String = JsonRenderer.renderFields( - "contentType" -> contentType, - "headers" -> headers, - "style" -> style, - "explode" -> explode, - "allowReserved" -> allowReserved, - ) + ) + + object Encoding { + implicit val schema: Schema[Encoding] = + DeriveSchema.gen[Encoding] } /** @@ -800,9 +910,38 @@ object OpenAPI { * contain at least one response code, and it SHOULD be the response for a * successful operation call. */ - type Responses = Map[Status, ResponseOrReference] + type Responses = Map[StatusOrDefault, ReferenceOr[Response]] + + sealed trait StatusOrDefault extends Product with Serializable { + def text: String + } + + object StatusOrDefault { + case class StatusValue(status: Status) extends StatusOrDefault { + override def text: String = status.text + } - sealed trait ResponseOrReference extends openapi.OpenAPIBase + object StatusValue { + implicit val schema: Schema[StatusValue] = + zio.schema + .Schema[Status] + .transformOrFail[StatusValue]( + s => Right(StatusValue(s)), + p => Right(p.status), + ) + } + case object Default extends StatusOrDefault { + implicit val schema: Schema[Default.type] = + zio.schema + .Schema[String] + .transformOrFail[Default.type]( + s => if (s == "default") Right(Default) else Left("Invalid default status"), + _ => Right("default"), + ) + + override def text: String = "default" + } + } /** * Describes a single response from an API Operation, including design-time, @@ -825,21 +964,17 @@ object OpenAPI { * of the names for Component Objects. */ final case class Response( - description: Doc, - headers: Map[String, HeaderOrReference], - content: Map[String, MediaType], - links: Map[String, LinkOrReference], - ) extends ResponseOrReference { - override def toJson: String = JsonRenderer.renderFields( - "description" -> description, - "headers" -> headers, - "content" -> content, - "links" -> links, - ) + description: Doc = Doc.Empty, + headers: Map[String, ReferenceOr[Header]] = Map.empty, + content: Map[String, MediaType] = Map.empty, + links: Map[String, ReferenceOr[Link]] = Map.empty, + ) + + object Response { + implicit val schema: Schema[Response] = + DeriveSchema.gen[Response] } - sealed trait CallbackOrReference extends openapi.OpenAPIBase - /** * A map of possible out-of band callbacks related to the parent operation. * Each value in the map is a Path Item Object that describes a set of @@ -852,16 +987,12 @@ object OpenAPI { * A Path Item Object used to define a callback request and expected * responses. */ - final case class Callback(expressions: Map[String, PathItem]) extends CallbackOrReference { - override def toJson: String = { - val toRender = expressions.foldLeft(List.empty[(String, Renderer[PathItem])]) { case (acc, (k, v)) => - (k, v: Renderer[PathItem]) :: acc - } - JsonRenderer.renderFields(toRender: _*) - } - } + final case class Callback(expressions: Map[String, PathItem] = Map.empty) - sealed trait ExampleOrReference extends openapi.OpenAPIBase + object Callback { + implicit val schema: Schema[Callback] = + DeriveSchema.gen[Callback] + } /** * In all cases, the example value is expected to be compatible with the type @@ -878,16 +1009,19 @@ object OpenAPI { * reference examples that cannot easily be included in JSON or YAML * documents. */ - final case class Example(summary: String = "", description: Doc, externalValue: URI) extends ExampleOrReference { - override def toJson: String = JsonRenderer.renderFields( - "summary" -> summary, - "description" -> description, - "externalValue" -> externalValue, - ) + // There is currently no API to set the summary, description or externalValue + final case class Example( + value: Json, + summary: Option[String] = None, + description: Option[Doc] = None, + externalValue: Option[URI] = None, + ) + + object Example { + implicit val schema: Schema[Example] = + DeriveSchema.gen[Example] } - sealed trait LinkOrReference extends openapi.OpenAPIBase - /** * The Link object represents a possible design-time link for a response. The * presence of a link does not guarantee the caller’s ability to successfully @@ -923,30 +1057,53 @@ object OpenAPI { */ final case class Link( operationRef: URI, - parameters: Map[String, LiteralOrExpression], + parameters: Map[String, LiteralOrExpression] = Map.empty, requestBody: LiteralOrExpression, - description: Doc, + description: Option[Doc], server: Option[Server], - ) extends LinkOrReference { - override def toJson: String = JsonRenderer.renderFields( - "operationRef" -> operationRef, - "parameters" -> parameters, - "requestBody" -> requestBody, - "description" -> description, - "server" -> server, - ) + ) + + object Link { + implicit val schema: Schema[Link] = + DeriveSchema.gen[Link] } sealed trait LiteralOrExpression object LiteralOrExpression { - final case class NumberLiteral(value: Long) extends LiteralOrExpression - final case class DecimalLiteral(value: Double) extends LiteralOrExpression - final case class StringLiteral(value: String) extends LiteralOrExpression - final case class BooleanLiteral(value: Boolean) extends LiteralOrExpression - sealed abstract case class Expression private (value: String) extends LiteralOrExpression + implicit val schema: Schema[LiteralOrExpression] = + DeriveSchema.gen[LiteralOrExpression] + + final case class NumberLiteral(value: Long) extends LiteralOrExpression + + object NumberLiteral { + implicit val schema: Schema[NumberLiteral] = + Schema[Long].transform[NumberLiteral](s => NumberLiteral(s), p => p.value) + } + final case class DecimalLiteral(value: Double) extends LiteralOrExpression + + object DecimalLiteral { + implicit val schema: Schema[DecimalLiteral] = + Schema[Double].transform[DecimalLiteral](s => DecimalLiteral(s), p => p.value) + } + final case class StringLiteral(value: String) extends LiteralOrExpression + + object StringLiteral { + implicit val schema: Schema[StringLiteral] = + Schema[String].transform[StringLiteral](s => StringLiteral(s), p => p.value) + } + final case class BooleanLiteral(value: Boolean) extends LiteralOrExpression + + object BooleanLiteral { + implicit val schema: Schema[BooleanLiteral] = + Schema[Boolean].transform[BooleanLiteral](s => BooleanLiteral(s), p => p.value) + } + case class Expression(value: String) extends LiteralOrExpression object Expression { - private[openapi] def create(value: String): Expression = new Expression(value) {} + implicit val schema: Schema[Expression] = + Schema[String].transform[Expression](s => Expression.create(s), p => p.value) + + private[openapi] def create(value: String): Expression = Expression(value) } // TODO: maybe one could make a regex to validate the expression. For now just accept anything @@ -972,13 +1129,7 @@ object OpenAPI { * @param externalDocs * Additional external documentation for this tag. */ - final case class Tag(name: String, description: Doc, externalDocs: URI) extends openapi.OpenAPIBase { - override def toJson: String = JsonRenderer.renderFields( - "name" -> name, - "description" -> description, - "externalDocs" -> externalDocs, - ) - } + final case class Tag(name: String, description: Option[Doc], externalDocs: Option[ExternalDoc]) /** * A simple object to allow referencing other components in the specification, @@ -987,130 +1138,40 @@ object OpenAPI { * @param ref * The reference string. */ - final case class Reference(ref: String) - extends SchemaOrReference - with ResponseOrReference - with ParameterOrReference - with ExampleOrReference - with RequestBodyOrReference - with HeaderOrReference - with SecuritySchemeOrReference - with LinkOrReference - with CallbackOrReference { - override def toJson: String = JsonRenderer.renderFields(s"$$ref" -> ref) - } - sealed trait SchemaOrReference extends openapi.OpenAPIBase - - sealed trait Schema extends openapi.OpenAPIBase with SchemaOrReference { - def nullable: Boolean - def discriminator: Option[Discriminator] - def readOnly: Boolean - def writeOnly: Boolean - def xml: Option[XML] - def externalDocs: URI - def example: String - def deprecated: Boolean - - override def toJson: String = - JsonRenderer.renderFields( - "nullable" -> nullable, - "discriminator" -> discriminator, - "readOnly" -> readOnly, - "writeOnly" -> writeOnly, - "xml" -> xml, - "externalDocs" -> externalDocs, - "example" -> example, - "deprecated" -> deprecated, - ) + @noDiscriminator + sealed trait ReferenceOr[+T] { + def asJsonSchema(implicit ev: T <:< JsonSchema): JsonSchema = this match { + case ReferenceOr.Reference(ref, summary, description) => + JsonSchema + .RefSchema(ref) + .description((summary.getOrElse(Doc.empty) + description.getOrElse(Doc.empty)).toCommonMark) + case ReferenceOr.Or(value) => ev(value) + } + } - object Schema { + object ReferenceOr { + implicit def schema[T: Schema]: Schema[ReferenceOr[T]] = + DeriveSchema.gen[ReferenceOr[T]] - /** - * The Schema Object allows the definition of input and output data types. - * - * Marked as readOnly. This means that it MAY be sent as part of a response - * but SHOULD NOT be sent as part of the request. If the property is in the - * required list, the required will take effect on the response only. - * - * @param nullable - * A true value adds "null" to the allowed type specified by the type - * keyword, only if type is explicitly defined within the same Schema - * Object. Other Schema Object constraints retain their defined behavior, - * and therefore may disallow the use of null as a value. A false value - * leaves the specified or default type unmodified. - * @param discriminator - * Adds support for polymorphism. The discriminator is an object name that - * is used to differentiate between other schemas which may satisfy the - * payload description. - * @param xml - * This MAY be used only on properties schemas. It has no effect on root - * schemas. Adds additional metadata to describe the XML representation of - * this property. - * @param externalDocs - * Additional external documentation for this schema. - * @param example - * A free-form property to include an example of an instance for this - * schema. - * @param deprecated - * Specifies that a schema is deprecated and SHOULD be transitioned out of - * usage. - */ - final case class ResponseSchema( - nullable: Boolean = false, - discriminator: Option[Discriminator], - xml: Option[XML], - externalDocs: URI, - example: String, - deprecated: Boolean = false, - ) extends Schema - with Parameter.Definition { - def readOnly: Boolean = true - def writeOnly: Boolean = false + final case class Reference( + @fieldName("$ref") ref: String, + summary: Option[Doc] = None, + description: Option[Doc] = None, + ) extends ReferenceOr[Nothing] + + object Reference { + implicit val schema: Schema[Reference] = + DeriveSchema.gen[Reference] } - /** - * The Schema Object allows the definition of input and output data types. - * - * Marked as writeOnly. This means that it MAY be sent as part of a request - * but SHOULD NOT be sent as part of the response. If the property is in the - * required list, the required will take effect on the request only. - * - * @param nullable - * A true value adds "null" to the allowed type specified by the type - * keyword, only if type is explicitly defined within the same Schema - * Object. Other Schema Object constraints retain their defined behavior, - * and therefore may disallow the use of null as a value. A false value - * leaves the specified or default type unmodified. - * @param discriminator - * Adds support for polymorphism. The discriminator is an object name that - * is used to differentiate between other schemas which may satisfy the - * payload description. - * @param xml - * This MAY be used only on properties schemas. It has no effect on root - * schemas. Adds additional metadata to describe the XML representation of - * this property. - * @param externalDocs - * Additional external documentation for this schema. - * @param example - * A free-form property to include an example of an instance for this - * schema. - * @param deprecated - * Specifies that a schema is deprecated and SHOULD be transitioned out of - * usage. - */ - final case class RequestSchema( - nullable: Boolean = false, - discriminator: Option[Discriminator], - xml: Option[XML], - externalDocs: URI, - example: String, - deprecated: Boolean = false, - ) extends Schema - with Parameter.Definition { - def readOnly: Boolean = false - def writeOnly: Boolean = true + final case class Or[T](value: T) extends ReferenceOr[T] + + object Or { + implicit def schema[T: Schema]: Schema[Or[T]] = + Schema[T].transform(Or(_), _.value) + } } @@ -1131,11 +1192,14 @@ object OpenAPI { * An object to hold mappings between payload values and schema names or * references. */ - final case class Discriminator(propertyName: String, mapping: Map[String, String]) extends openapi.OpenAPIBase { - override def toJson: String = JsonRenderer.renderFields( - "propertyName" -> propertyName, - "mapping" -> mapping, - ) + final case class Discriminator( + propertyName: String, + mapping: Map[String, String] = Map.empty, + ) + + object Discriminator { + implicit val schema: Schema[Discriminator] = + DeriveSchema.gen[Discriminator] } /** @@ -1164,25 +1228,17 @@ object OpenAPI { * type being array (outside the items). */ final case class XML(name: String, namespace: URI, prefix: String, attribute: Boolean = false, wrapped: Boolean) - extends openapi.OpenAPIBase { - override def toJson: String = JsonRenderer.renderFields( - "name" -> name, - "namespace" -> namespace, - "prefix" -> prefix, - "attribute" -> attribute, - "wrapped" -> wrapped, - ) - } - sealed trait SecuritySchemeOrReference extends openapi.OpenAPIBase - - sealed trait SecurityScheme extends SecuritySchemeOrReference { + sealed trait SecurityScheme { def `type`: String - def description: Doc + def description: Option[Doc] } object SecurityScheme { + implicit val schema: Schema[SecurityScheme] = + DeriveSchema.gen[SecurityScheme] + /** * Defines an HTTP security scheme that can be used by the operations. * @@ -1193,28 +1249,18 @@ object OpenAPI { * @param in * The location of the API key. */ - final case class ApiKey(description: Doc, name: String, in: ApiKey.In) extends SecurityScheme { + final case class ApiKey(description: Option[Doc], name: String, in: ApiKey.In) extends SecurityScheme { override def `type`: String = "apiKey" - - override def toJson: String = - JsonRenderer.renderFields( - "type" -> `type`, - "description" -> description, - "name" -> name, - "in" -> in, - ) } object ApiKey { - sealed trait In extends openapi.OpenAPIBase { - self: Product => - override def toJson: String = - s""""${self.productPrefix.updated(0, self.productPrefix.charAt(0).toLower)}"""" - } + sealed trait In extends Product with Serializable object In { - case object Query extends In + case object Query extends In + case object Header extends In + case object Cookie extends In } } @@ -1231,16 +1277,10 @@ object OpenAPI { * Bearer tokens are usually generated by an authorization server, so this * information is primarily for documentation purposes. */ - final case class Http(description: Doc, scheme: String, bearerFormat: Option[String]) extends SecurityScheme { + final case class Http(description: Option[Doc], scheme: String, bearerFormat: Option[String]) + extends SecurityScheme { override def `type`: String = "http" - override def toJson: String = - JsonRenderer.renderFields( - "type" -> `type`, - "description" -> description, - "scheme" -> scheme, - "bearerFormat" -> bearerFormat, - ) } /** @@ -1250,15 +1290,9 @@ object OpenAPI { * An object containing configuration information for the flow types * supported. */ - final case class OAuth2(description: Doc, flows: OAuthFlows) extends SecurityScheme { + final case class OAuth2(description: Option[Doc], flows: OAuthFlows) extends SecurityScheme { override def `type`: String = "oauth2" - override def toJson: String = - JsonRenderer.renderFields( - "type" -> `type`, - "description" -> description, - "flows" -> flows, - ) } /** @@ -1267,165 +1301,124 @@ object OpenAPI { * @param openIdConnectUrl * OpenId Connect URL to discover OAuth2 configuration values. */ - final case class OpenIdConnect(description: Doc, openIdConnectUrl: URI) extends SecurityScheme { + final case class OpenIdConnect(description: Option[Doc], openIdConnectUrl: URI) extends SecurityScheme { override def `type`: String = "openIdConnect" - override def toJson: String = - JsonRenderer.renderFields( - "type" -> `type`, - "description" -> description, - "openIdConnectUrl" -> openIdConnectUrl, - ) } - } - - /** - * Allows configuration of the supported OAuth Flows. - * - * @param `implicit` - * Configuration for the OAuth Implicit flow. - * @param password - * Configuration for the OAuth Resource Owner Password flow - * @param clientCredentials - * Configuration for the OAuth Client Credentials flow. Previously called - * application in OpenAPI 2.0. - * @param authorizationCode - * Configuration for the OAuth Authorization Code flow. Previously called - * accessCode in OpenAPI 2.0. - */ - final case class OAuthFlows( - `implicit`: Option[OAuthFlow.Implicit], - password: Option[OAuthFlow.Password], - clientCredentials: Option[OAuthFlow.ClientCredentials], - authorizationCode: Option[OAuthFlow.AuthorizationCode], - ) extends openapi.OpenAPIBase { - override def toJson: String = JsonRenderer.renderFields( - "implicit" -> `implicit`, - "password" -> password, - "clientCredentials" -> clientCredentials, - "authorizationCode" -> authorizationCode, - ) - } - - sealed trait OAuthFlow extends openapi.OpenAPIBase { - def refreshUrl: Option[URI] - def scopes: Map[String, String] - } - - object OAuthFlow { /** - * Configuration for the OAuth Implicit flow. + * Allows configuration of the supported OAuth Flows. * - * @param authorizationUrl - * The authorization URL to be used for this flow. - * @param refreshUrl - * The URL to be used for obtaining refresh tokens. - * @param scopes - * The available scopes for the OAuth2 security scheme. A map between the - * scope name and a short description for it. The map MAY be empty. + * @param `implicit` + * Configuration for the OAuth Implicit flow. + * @param password + * Configuration for the OAuth Resource Owner Password flow + * @param clientCredentials + * Configuration for the OAuth Client Credentials flow. Previously called + * application in OpenAPI 2.0. + * @param authorizationCode + * Configuration for the OAuth Authorization Code flow. Previously called + * accessCode in OpenAPI 2.0. */ - final case class Implicit(authorizationUrl: URI, refreshUrl: Option[URI], scopes: Map[String, String]) - extends OAuthFlow { - override def toJson: String = JsonRenderer.renderFields( - "authorizationUrl" -> authorizationUrl, - "refreshUrl" -> refreshUrl, - "scopes" -> scopes, - ) - } + final case class OAuthFlows( + `implicit`: Option[OAuthFlow.Implicit], + password: Option[OAuthFlow.Password], + clientCredentials: Option[OAuthFlow.ClientCredentials], + authorizationCode: Option[OAuthFlow.AuthorizationCode], + ) - /** - * Configuration for the OAuth Authorization Code flow. Previously called - * accessCode in OpenAPI 2.0. - * - * @param authorizationUrl - * The authorization URL to be used for this flow. - * @param refreshUrl - * The URL to be used for obtaining refresh tokens. - * @param scopes - * The available scopes for the OAuth2 security scheme. A map between the - * scope name and a short description for it. The map MAY be empty. - * @param tokenUrl - * The token URL to be used for this flow. - */ - final case class AuthorizationCode( - authorizationUrl: URI, - refreshUrl: Option[URI], - scopes: Map[String, String], - tokenUrl: URI, - ) extends OAuthFlow { - override def toJson: String = JsonRenderer.renderFields( - "authorizationUrl" -> authorizationUrl, - "refreshUrl" -> refreshUrl, - "scopes" -> scopes, - "tokenUrl" -> tokenUrl, - ) + sealed trait OAuthFlow { + def refreshUrl: Option[URI] + + def scopes: Map[String, String] } - /** - * Configuration for the OAuth Resource Owner Password flow. - * - * @param refreshUrl - * The URL to be used for obtaining refresh tokens. - * @param scopes - * The available scopes for the OAuth2 security scheme. A map between the - * scope name and a short description for it. The map MAY be empty. - * @param tokenUrl - * The token URL to be used for this flow. - */ - final case class Password(refreshUrl: Option[URI], scopes: Map[String, String], tokenUrl: URI) extends OAuthFlow { - override def toJson: String = JsonRenderer.renderFields( - "refreshUrl" -> refreshUrl, - "scopes" -> scopes, - "tokenUrl" -> tokenUrl, - ) + object OAuthFlow { + + /** + * Configuration for the OAuth Implicit flow. + * + * @param authorizationUrl + * The authorization URL to be used for this flow. + * @param refreshUrl + * The URL to be used for obtaining refresh tokens. + * @param scopes + * The available scopes for the OAuth2 security scheme. A map between + * the scope name and a short description for it. The map MAY be empty. + */ + final case class Implicit(authorizationUrl: URI, refreshUrl: Option[URI], scopes: Map[String, String]) + extends OAuthFlow + + /** + * Configuration for the OAuth Authorization Code flow. Previously called + * accessCode in OpenAPI 2.0. + * + * @param authorizationUrl + * The authorization URL to be used for this flow. + * @param refreshUrl + * The URL to be used for obtaining refresh tokens. + * @param scopes + * The available scopes for the OAuth2 security scheme. A map between + * the scope name and a short description for it. The map MAY be empty. + * @param tokenUrl + * The token URL to be used for this flow. + */ + final case class AuthorizationCode( + authorizationUrl: URI, + refreshUrl: Option[URI], + scopes: Map[String, String], + tokenUrl: URI, + ) extends OAuthFlow + + /** + * Configuration for the OAuth Resource Owner Password flow. + * + * @param refreshUrl + * The URL to be used for obtaining refresh tokens. + * @param scopes + * The available scopes for the OAuth2 security scheme. A map between + * the scope name and a short description for it. The map MAY be empty. + * @param tokenUrl + * The token URL to be used for this flow. + */ + final case class Password(refreshUrl: Option[URI], scopes: Map[String, String], tokenUrl: URI) extends OAuthFlow + + /** + * Configuration for the OAuth Client Credentials flow. Previously called + * application in OpenAPI 2.0. + * + * @param refreshUrl + * The URL to be used for obtaining refresh tokens. + * @param scopes + * The available scopes for the OAuth2 security scheme. A map between + * the scope name and a short description for it. The map MAY be empty. + * @param tokenUrl + * The token URL to be used for this flow. + */ + final case class ClientCredentials(refreshUrl: Option[URI], scopes: Map[String, String], tokenUrl: URI) + extends OAuthFlow {} } /** - * Configuration for the OAuth Client Credentials flow. Previously called - * application in OpenAPI 2.0. + * Lists the required security schemes to execute this operation. The name + * used for each property MUST correspond to a security scheme declared in + * the Security Schemes under the Components Object. + * + * Security Requirement Objects that contain multiple schemes require that + * all schemes MUST be satisfied for a request to be authorized. This + * enables support for scenarios where multiple query parameters or HTTP + * headers are required to convey security information. * - * @param refreshUrl - * The URL to be used for obtaining refresh tokens. - * @param scopes - * The available scopes for the OAuth2 security scheme. A map between the - * scope name and a short description for it. The map MAY be empty. - * @param tokenUrl - * The token URL to be used for this flow. + * When a list of Security Requirement Objects is defined on the OpenAPI + * Object or Operation Object, only one of the Security Requirement Objects + * in the list needs to be satisfied to authorize the request. + * + * @param securitySchemes + * If the security scheme is of type "oauth2" or "openIdConnect", then the + * value is a list of scope names required for the execution, and the list + * MAY be empty if authorization does not require a specified scope. For + * other security scheme types, the List MUST be empty. */ - final case class ClientCredentials(refreshUrl: Option[URI], scopes: Map[String, String], tokenUrl: URI) - extends OAuthFlow { - override def toJson: String = JsonRenderer.renderFields( - "refreshUrl" -> refreshUrl, - "scopes" -> scopes, - "tokenUrl" -> tokenUrl, - ) - } - } - - /** - * Lists the required security schemes to execute this operation. The name - * used for each property MUST correspond to a security scheme declared in the - * Security Schemes under the Components Object. - * - * Security Requirement Objects that contain multiple schemes require that all - * schemes MUST be satisfied for a request to be authorized. This enables - * support for scenarios where multiple query parameters or HTTP headers are - * required to convey security information. - * - * When a list of Security Requirement Objects is defined on the OpenAPI - * Object or Operation Object, only one of the Security Requirement Objects in - * the list needs to be satisfied to authorize the request. - * - * @param securitySchemes - * If the security scheme is of type "oauth2" or "openIdConnect", then the - * value is a list of scope names required for the execution, and the list - * MAY be empty if authorization does not require a specified scope. For - * other security scheme types, the List MUST be empty. - */ - final case class SecurityRequirement(securitySchemes: Map[String, List[String]]) extends openapi.OpenAPIBase { - override def toJson: String = JsonRenderer.renderFields( - "securitySchemes" -> securitySchemes, - ) + final case class SecurityRequirement(securitySchemes: Map[String, List[String]]) } } diff --git a/zio-http/src/main/scala/zio/http/endpoint/openapi/OpenAPIGen.scala b/zio-http/src/main/scala/zio/http/endpoint/openapi/OpenAPIGen.scala new file mode 100644 index 0000000000..c2460e942d --- /dev/null +++ b/zio-http/src/main/scala/zio/http/endpoint/openapi/OpenAPIGen.scala @@ -0,0 +1,819 @@ +package zio.http.endpoint.openapi + +import java.util.UUID + +import scala.annotation.tailrec +import scala.collection.{immutable, mutable} + +import zio.Chunk +import zio.json.EncoderOps +import zio.json.ast.Json + +import zio.schema.Schema.Record +import zio.schema.codec.JsonCodec +import zio.schema.{Schema, TypeId} + +import zio.http._ +import zio.http.codec.HttpCodec.Metadata +import zio.http.codec._ +import zio.http.endpoint._ +import zio.http.endpoint.openapi.JsonSchema.SchemaStyle + +object OpenAPIGen { + private val PathWildcard = "pathWildcard" + + private[openapi] def groupMap[A, K, B](chunk: Chunk[A])(key: A => K)(f: A => B): immutable.Map[K, Chunk[B]] = { + val m = mutable.Map.empty[K, mutable.Builder[B, Chunk[B]]] + for (elem <- chunk) { + val k = key(elem) + val bldr = m.getOrElseUpdate(k, Chunk.newBuilder[B]) + bldr += f(elem) + } + class Result extends runtime.AbstractFunction1[(K, mutable.Builder[B, Chunk[B]]), Unit] { + var built = immutable.Map.empty[K, Chunk[B]] + + def apply(kv: (K, mutable.Builder[B, Chunk[B]])): Unit = + built = built.updated(kv._1, kv._2.result()) + } + val result = new Result + m.foreach(result) + result.built + } + + final case class MetaCodec[T](codec: T, annotations: Chunk[HttpCodec.Metadata[Any]]) { + lazy val docs: Doc = { + val annotatedDoc = annotations.foldLeft(Doc.empty) { + case (doc, HttpCodec.Metadata.Documented(nextDoc)) => doc + nextDoc + case (doc, _) => doc + } + val trailingPathDoc = codec.asInstanceOf[Any] match { + case SegmentCodec.Trailing => + Doc.p( + Doc.Span.bold("WARNING: This is wildcard path segment. There is no official OpenAPI support for this."), + ) + + Doc.p("Tools might URL encode this segment and it might not work as expected.") + case _ => + Doc.empty + } + annotatedDoc + trailingPathDoc + } + + lazy val docsOpt: Option[Doc] = if (docs.isEmpty) None else Some(docs) + + lazy val examples: Map[String, Any] = annotations.foldLeft(Map.empty[String, Any]) { + case (examples, HttpCodec.Metadata.Examples(nextExamples)) => examples ++ nextExamples + case (examples, _) => examples + } + + def examples(schema: Schema[_]): Map[String, OpenAPI.ReferenceOr.Or[OpenAPI.Example]] = + examples.map { case (k, v) => + k -> OpenAPI.ReferenceOr.Or(OpenAPI.Example(toJsonAst(schema, v))) + } + + def name: Option[String] = + codec match { + case value: SegmentCodec[_] => + value match { + case SegmentCodec.BoolSeg(name) => Some(name) + case SegmentCodec.IntSeg(name) => Some(name) + case SegmentCodec.LongSeg(name) => Some(name) + case SegmentCodec.Text(name) => Some(name) + case SegmentCodec.UUID(name) => Some(name) + case SegmentCodec.Trailing => Some(PathWildcard) + case _ => None + } + case _ => + findName(annotations) + } + + def required: Boolean = + !annotations.exists(_.isInstanceOf[HttpCodec.Metadata.Optional[_]]) + + def deprecated: Boolean = + annotations.exists(_.isInstanceOf[HttpCodec.Metadata.Deprecated[_]]) + } + final case class AtomizedMetaCodecs( + method: Chunk[MetaCodec[SimpleCodec[Method, _]]], + path: Chunk[MetaCodec[SegmentCodec[_]]], + query: Chunk[MetaCodec[HttpCodec.Query[_]]], + header: Chunk[MetaCodec[HttpCodec.Header[_]]], + content: Chunk[MetaCodec[HttpCodec.Atom[HttpCodecType.Content, _]]], + status: Chunk[MetaCodec[HttpCodec.Status[_]]], + ) { + def append(metaCodec: MetaCodec[_]): AtomizedMetaCodecs = metaCodec match { + case MetaCodec(codec: HttpCodec.Method[_], annotations) => + copy(method = + (method :+ MetaCodec(codec.codec, annotations)).asInstanceOf[Chunk[MetaCodec[SimpleCodec[Method, _]]]], + ) + case MetaCodec(_: SegmentCodec[_], _) => + copy(path = path :+ metaCodec.asInstanceOf[MetaCodec[SegmentCodec[_]]]) + case MetaCodec(_: HttpCodec.Query[_], _) => + copy(query = query :+ metaCodec.asInstanceOf[MetaCodec[HttpCodec.Query[_]]]) + case MetaCodec(_: HttpCodec.Header[_], _) => + copy(header = header :+ metaCodec.asInstanceOf[MetaCodec[HttpCodec.Header[_]]]) + case MetaCodec(_: HttpCodec.Status[_], _) => + copy(status = status :+ metaCodec.asInstanceOf[MetaCodec[HttpCodec.Status[_]]]) + case MetaCodec(_: HttpCodec.Content[_], _) => + copy(content = content :+ metaCodec.asInstanceOf[MetaCodec[HttpCodec.Atom[HttpCodecType.Content, _]]]) + case MetaCodec(_: HttpCodec.ContentStream[_], _) => + copy(content = content :+ metaCodec.asInstanceOf[MetaCodec[HttpCodec.Atom[HttpCodecType.Content, _]]]) + case _ => this + } + + def ++(that: AtomizedMetaCodecs): AtomizedMetaCodecs = + AtomizedMetaCodecs( + method ++ that.method, + path ++ that.path, + query ++ that.query, + header ++ that.header, + content ++ that.content, + status ++ that.status, + ) + + def contentExamples: Map[String, OpenAPI.ReferenceOr.Or[OpenAPI.Example]] = + content.flatMap { + case mc @ MetaCodec(HttpCodec.Content(schema, _, _, _), _) => + mc.examples.map { case (name, value) => + name -> OpenAPI.ReferenceOr.Or(OpenAPI.Example(toJsonAst(schema, value))) + } + case mc @ MetaCodec(HttpCodec.ContentStream(schema, _, _, _), _) => + mc.examples.map { case (name, value) => + name -> OpenAPI.ReferenceOr.Or(OpenAPI.Example(toJsonAst(schema, value))) + } + case _ => + Map.empty[String, OpenAPI.ReferenceOr.Or[OpenAPI.Example]] + }.toMap + + // in case of alternatives, + // the doc to the alternation is added to all sub elements of the alternatives. + // This is not ideal. But it is the best we can do. + // To get the doc that is only for the alternation, we take the intersection of all docs, + // since only the alternation doc is added to all sub elements. + def contentDocs: Doc = + content + .flatMap(_.docsOpt) + .map(_.flattened) + .reduceOption(_ intersect _) + .flatMap(_.reduceOption(_ + _)) + .getOrElse(Doc.empty) + + def optimize: AtomizedMetaCodecs = + AtomizedMetaCodecs( + method.materialize, + path.materialize, + query.materialize, + header.materialize, + content.materialize, + status.materialize, + ) + } + + object AtomizedMetaCodecs { + def empty: AtomizedMetaCodecs = AtomizedMetaCodecs( + method = Chunk.empty, + path = Chunk.empty, + query = Chunk.empty, + header = Chunk.empty, + content = Chunk.empty, + status = Chunk.empty, + ) + + def flatten[R, A](codec: HttpCodec[R, A]): AtomizedMetaCodecs = { + val atoms = flattenedAtoms(codec) + + val flattened = atoms + .foldLeft(AtomizedMetaCodecs.empty) { case (acc, atom) => + acc.append(atom) + } + .optimize + flattened + } + + private def flattenedAtoms[R, A]( + in: HttpCodec[R, A], + annotations: Chunk[HttpCodec.Metadata[Any]] = Chunk.empty, + ): Chunk[MetaCodec[_]] = + in match { + case HttpCodec.Combine(left, right, _) => + flattenedAtoms(left, annotations) ++ flattenedAtoms(right, annotations) + case path: HttpCodec.Path[_] => Chunk.fromIterable(path.pathCodec.segments.map(metaCodecFromSegment)) + case atom: HttpCodec.Atom[_, _] => Chunk(MetaCodec(atom, annotations)) + case map: HttpCodec.TransformOrFail[_, _, _] => flattenedAtoms(map.api, annotations) + case HttpCodec.Empty => Chunk.empty + case HttpCodec.Halt => Chunk.empty + case _: HttpCodec.Fallback[_, _, _] => in.alternatives.map(_._1).flatMap(flattenedAtoms(_, annotations)) + case HttpCodec.Annotated(api, annotation) => + flattenedAtoms(api, annotations :+ annotation.asInstanceOf[HttpCodec.Metadata[Any]]) + } + } + + private def metaCodecFromSegment(segment: SegmentCodec[_]) = { + segment match { + case SegmentCodec.Annotated(codec, annotations) => + MetaCodec( + codec, + annotations.map { + case SegmentCodec.MetaData.Documented(value) => HttpCodec.Metadata.Documented(value) + case SegmentCodec.MetaData.Examples(examples) => HttpCodec.Metadata.Examples(examples) + }.asInstanceOf[Chunk[HttpCodec.Metadata[Any]]], + ) + case other => MetaCodec(other, Chunk.empty) + } + } + + def contentAsJsonSchema[R, A]( + codec: HttpCodec[R, A], + metadata: Chunk[HttpCodec.Metadata[_]] = Chunk.empty, + referenceType: SchemaStyle = SchemaStyle.Inline, + wrapInObject: Boolean = false, + ): JsonSchema = { + codec match { + case atom: HttpCodec.Atom[_, _] => + atom match { + case HttpCodec.Content(schema, _, maybeName, _) if wrapInObject => + val name = + findName(metadata).orElse(maybeName).getOrElse(throw new Exception("Multipart content without name")) + JsonSchema.obj( + name -> JsonSchema + .fromZSchema(schema, referenceType) + .description(description(metadata)) + .deprecated(deprecated(metadata)) + .nullable(optional(metadata)), + ) + case HttpCodec.ContentStream(schema, _, maybeName, _) if wrapInObject && schema == Schema[Byte] => + val name = + findName(metadata).orElse(maybeName).getOrElse(throw new Exception("Multipart content without name")) + JsonSchema.obj( + name -> JsonSchema + .fromZSchema(schema, referenceType) + .description(description(metadata)) + .deprecated(deprecated(metadata)) + .nullable(optional(metadata)) + // currently we have no information about the encoding. So we just assume binary + .contentEncoding(JsonSchema.ContentEncoding.Binary) + .contentMediaType(MediaType.application.`octet-stream`.fullType), + ) + case HttpCodec.ContentStream(schema, _, maybeName, _) if wrapInObject => + val name = + findName(metadata).orElse(maybeName).getOrElse(throw new Exception("Multipart content without name")) + JsonSchema.obj( + name -> JsonSchema + .fromZSchema(schema, referenceType) + .description(description(metadata)) + .deprecated(deprecated(metadata)) + .nullable(optional(metadata)), + ) + case HttpCodec.Content(schema, _, _, _) => + JsonSchema + .fromZSchema(schema, referenceType) + .description(description(metadata)) + .deprecated(deprecated(metadata)) + .nullable(optional(metadata)) + case HttpCodec.ContentStream(schema, _, _, _) => + JsonSchema + .fromZSchema(schema, referenceType) + .description(description(metadata)) + .deprecated(deprecated(metadata)) + .nullable(optional(metadata)) + case _ => JsonSchema.Null + } + case HttpCodec.Annotated(codec, data) => + contentAsJsonSchema(codec, metadata :+ data, referenceType, wrapInObject) + case HttpCodec.TransformOrFail(api, _, _) => contentAsJsonSchema(api, metadata, referenceType, wrapInObject) + case HttpCodec.Empty => JsonSchema.Null + case HttpCodec.Halt => JsonSchema.Null + case HttpCodec.Combine(left, right, _) if isMultipart(codec) => + ( + contentAsJsonSchema(left, Chunk.empty, referenceType, wrapInObject = true), + contentAsJsonSchema(right, Chunk.empty, referenceType, wrapInObject = true), + ) match { + case (left, right) => + val annotations = left.annotations ++ right.annotations + (left.withoutAnnotations, right.withoutAnnotations) match { + case (JsonSchema.Object(p1, _, r1), JsonSchema.Object(p2, _, r2)) => + // seems odd to allow additional properties for multipart. So just hardcode it to false + JsonSchema + .Object(p1 ++ p2, Left(false), r1 ++ r2) + .deprecated(deprecated(metadata)) + .nullable(optional(metadata)) + .description(description(metadata)) + .annotate(annotations) + case _ => throw new IllegalArgumentException("Multipart content without name.") + } + + } + case HttpCodec.Combine(left, right, _) => + ( + contentAsJsonSchema(left, Chunk.empty, referenceType, wrapInObject), + contentAsJsonSchema(right, Chunk.empty, referenceType, wrapInObject), + ) match { + case (JsonSchema.Null, JsonSchema.Null) => + JsonSchema.Null + case (JsonSchema.Null, schema) => + schema + .deprecated(deprecated(metadata)) + .nullable(optional(metadata)) + .description(description(metadata)) + case (schema, JsonSchema.Null) => + schema + .deprecated(deprecated(metadata)) + .nullable(optional(metadata)) + .description(description(metadata)) + case _ => + throw new IllegalStateException("A non multipart combine, should lead to at least one null schema.") + } + case HttpCodec.Fallback(_, _, _) => throw new IllegalArgumentException("Fallback not supported at this point") + } + } + + private def findName(metadata: Chunk[HttpCodec.Metadata[_]]): Option[String] = + metadata.reverse + .find(_.isInstanceOf[Metadata.Named[_]]) + .asInstanceOf[Option[Metadata.Named[Any]]] + .map(_.name) + + private def description(metadata: Chunk[HttpCodec.Metadata[_]]): Option[String] = + metadata.collect { case HttpCodec.Metadata.Documented(doc) => doc } + .reduceOption(_ + _) + .map(_.toCommonMark) + + private def deprecated(metadata: Chunk[HttpCodec.Metadata[_]]): Boolean = + metadata.exists(_.isInstanceOf[HttpCodec.Metadata.Deprecated[_]]) + + private def optional(metadata: Chunk[HttpCodec.Metadata[_]]): Boolean = + metadata.exists(_.isInstanceOf[HttpCodec.Metadata.Optional[_]]) + + def status[R, A](codec: HttpCodec[R, A]): Option[Status] = + codec match { + case HttpCodec.Status(simpleCodec, _) if simpleCodec.isInstanceOf[SimpleCodec.Specified[_]] => + Some(simpleCodec.asInstanceOf[SimpleCodec.Specified[Status]].value) + case HttpCodec.Annotated(codec, _) => + status(codec) + case HttpCodec.TransformOrFail(api, _, _) => + status(api) + case HttpCodec.Empty => + None + case HttpCodec.Halt => + None + case HttpCodec.Combine(left, right, _) => + status(left).orElse(status(right)) + case HttpCodec.Fallback(left, right, _) => + status(left).orElse(status(right)) + case _ => + None + } + + def isMultipart[R, A](codec: HttpCodec[R, A]): Boolean = + codec match { + case HttpCodec.Combine(left, right, _) => + (isContent(left) && isContent(right)) || + isMultipart(left) || isMultipart(right) + case HttpCodec.Annotated(codec, _) => isMultipart(codec) + case HttpCodec.TransformOrFail(codec, _, _) => isMultipart(codec) + case _ => false + } + + def isContent(value: HttpCodec[_, _]): Boolean = + value match { + case HttpCodec.Content(_, _, _, _) => true + case HttpCodec.ContentStream(_, _, _, _) => true + case HttpCodec.Annotated(codec, _) => isContent(codec) + case HttpCodec.TransformOrFail(codec, _, _) => isContent(codec) + case HttpCodec.Combine(left, right, _) => isContent(left) || isContent(right) + case _ => false + } + + private def toJsonAst(schema: Schema[_], v: Any): Json = + JsonCodec + .jsonEncoder(schema.asInstanceOf[Schema[Any]]) + .toJsonAST(v) + .toOption + .get + + def fromEndpoints( + endpoint1: Endpoint[_, _, _, _, _], + endpoints: Endpoint[_, _, _, _, _]*, + ): OpenAPI = fromEndpoints(endpoint1 +: endpoints) + + def fromEndpoints( + title: String, + version: String, + endpoint1: Endpoint[_, _, _, _, _], + endpoints: Endpoint[_, _, _, _, _]*, + ): OpenAPI = fromEndpoints(title, version, endpoint1 +: endpoints) + + def fromEndpoints( + title: String, + version: String, + referenceType: SchemaStyle, + endpoint1: Endpoint[_, _, _, _, _], + endpoints: Endpoint[_, _, _, _, _]*, + ): OpenAPI = fromEndpoints(title, version, referenceType, endpoint1 +: endpoints) + + def fromEndpoints( + referenceType: SchemaStyle, + endpoints: Iterable[Endpoint[_, _, _, _, _]], + ): OpenAPI = if (endpoints.isEmpty) OpenAPI.empty else endpoints.map(gen(_, referenceType)).reduce(_ ++ _) + + def fromEndpoints( + endpoints: Iterable[Endpoint[_, _, _, _, _]], + ): OpenAPI = if (endpoints.isEmpty) OpenAPI.empty else endpoints.map(gen(_, SchemaStyle.Compact)).reduce(_ ++ _) + + def fromEndpoints( + title: String, + version: String, + endpoints: Iterable[Endpoint[_, _, _, _, _]], + ): OpenAPI = fromEndpoints(endpoints).title(title).version(version) + + def fromEndpoints( + title: String, + version: String, + referenceType: SchemaStyle, + endpoints: Iterable[Endpoint[_, _, _, _, _]], + ): OpenAPI = fromEndpoints(referenceType, endpoints).title(title).version(version) + + def gen( + endpoint: Endpoint[_, _, _, _, _], + referenceType: SchemaStyle = SchemaStyle.Compact, + ): OpenAPI = { + val inAtoms = AtomizedMetaCodecs.flatten(endpoint.input) + val outs: Map[OpenAPI.StatusOrDefault, Map[MediaType, (JsonSchema, AtomizedMetaCodecs)]] = + schemaByStatusAndMediaType( + endpoint.output.alternatives.map(_._1) ++ endpoint.error.alternatives.map(_._1), + referenceType, + ) + // there is no status for inputs. So we just take the first one (default) + val ins = schemaByStatusAndMediaType(endpoint.input.alternatives.map(_._1), referenceType).values.headOption + + def path: OpenAPI.Paths = { + val path = buildPath(endpoint.input) + val method0 = method(inAtoms.method) + // Endpoint has only one doc. But open api has a summery and a description + val pathItem = OpenAPI.PathItem.empty + .copy(description = Some(endpoint.doc + endpoint.input.doc.getOrElse(Doc.empty)).filter(!_.isEmpty)) + val pathItemWithOp = method0 match { + case Method.OPTIONS => pathItem.addOptions(operation(endpoint)) + case Method.GET => pathItem.addGet(operation(endpoint)) + case Method.HEAD => pathItem.addHead(operation(endpoint)) + case Method.POST => pathItem.addPost(operation(endpoint)) + case Method.PUT => pathItem.addPut(operation(endpoint)) + case Method.PATCH => pathItem.addPatch(operation(endpoint)) + case Method.DELETE => pathItem.addDelete(operation(endpoint)) + case Method.TRACE => pathItem.addTrace(operation(endpoint)) + case Method.ANY => pathItem.any(operation(endpoint)) + case method => throw new IllegalArgumentException(s"OpenAPI does not support method $method") + } + Map(path -> pathItemWithOp) + } + + def buildPath(in: HttpCodec[_, _]): OpenAPI.Path = { + + def pathCodec(in1: HttpCodec[_, _]): Option[HttpCodec.Path[_]] = in1 match { + case atom: HttpCodec.Atom[_, _] => + atom match { + case codec @ HttpCodec.Path(_, _) => Some(codec) + case _ => None + } + case HttpCodec.Annotated(in, _) => pathCodec(in) + case HttpCodec.TransformOrFail(api, _, _) => pathCodec(api) + case HttpCodec.Empty => None + case HttpCodec.Halt => None + case HttpCodec.Combine(left, right, _) => pathCodec(left).orElse(pathCodec(right)) + case HttpCodec.Fallback(left, right, _) => pathCodec(left).orElse(pathCodec(right)) + } + + val pathString = { + val codec = pathCodec(in).getOrElse(throw new Exception("No path found.")).pathCodec + if (codec.render.endsWith(SegmentCodec.Trailing.render)) + codec.renderIgnoreTrailing + s"{$PathWildcard}" + else codec.render + } + OpenAPI.Path.fromString(pathString).getOrElse(throw new Exception(s"Invalid path: $pathString")) + } + + def method(in: Chunk[MetaCodec[SimpleCodec[Method, _]]]): Method = { + if (in.size > 1) throw new Exception("Multiple methods not supported") + in.collectFirst { case MetaCodec(SimpleCodec.Specified(method: Method), _) => method } + .getOrElse(throw new Exception("No method specified")) + } + + def operation(endpoint: Endpoint[_, _, _, _, _]): OpenAPI.Operation = + OpenAPI.Operation( + tags = Nil, + summary = None, + description = Some(endpoint.doc + pathDoc).filter(!_.isEmpty), + externalDocs = None, + operationId = None, + parameters = parameters, + requestBody = requestBody, + responses = responses, + callbacks = Map.empty, + security = Nil, + servers = Nil, + ) + + def pathDoc: Doc = { + def loop(codec: PathCodec[_]): Doc = codec match { + case PathCodec.Segment(_) => + // segment docs are used in path parameters + Doc.empty + case PathCodec.Concat(left, right, _, _) => + loop(left) + loop(right) + case PathCodec.TransformOrFail(api, _, _) => + loop(api) + } + loop(endpoint.route.pathCodec) + } + + def requestBody: Option[OpenAPI.ReferenceOr[OpenAPI.RequestBody]] = + ins.map { mediaTypes => + val combinedAtomizedCodecs = mediaTypes.map { case (_, (_, atomized)) => atomized }.reduce(_ ++ _) + val mediaTypeResponses = mediaTypes.map { case (mediaType, (schema, atomized)) => + mediaType.fullType -> OpenAPI.MediaType( + schema = OpenAPI.ReferenceOr.Or(schema), + examples = atomized.contentExamples, + encoding = Map.empty, + ) + } + OpenAPI.ReferenceOr.Or( + OpenAPI.RequestBody( + content = mediaTypeResponses, + required = combinedAtomizedCodecs.content.exists(_.required), + ), + ) + } + + def responses: OpenAPI.Responses = + responsesForAlternatives(outs) + + def parameters: Set[OpenAPI.ReferenceOr[OpenAPI.Parameter]] = + queryParams ++ pathParams ++ headerParams + + def queryParams: Set[OpenAPI.ReferenceOr[OpenAPI.Parameter]] = { + inAtoms.query.collect { case mc @ MetaCodec(HttpCodec.Query(name, codec, _), _) => + OpenAPI.ReferenceOr.Or( + OpenAPI.Parameter.queryParameter( + name = name, + description = mc.docsOpt, + schema = Some(OpenAPI.ReferenceOr.Or(JsonSchema.fromTextCodec(codec))), + deprecated = mc.deprecated, + style = OpenAPI.Parameter.Style.Form, + explode = false, + allowReserved = false, + examples = mc.examples.map { case (name, value) => + name -> OpenAPI.ReferenceOr.Or(OpenAPI.Example(value = Json.Str(value.toString))) + }, + required = mc.required, + ), + ) + } + }.toSet + + def pathParams: Set[OpenAPI.ReferenceOr[OpenAPI.Parameter]] = + inAtoms.path.collect { + case mc @ MetaCodec(codec, _) if codec != SegmentCodec.Empty && !codec.isInstanceOf[SegmentCodec.Literal] => + OpenAPI.ReferenceOr.Or( + OpenAPI.Parameter.pathParameter( + name = mc.name.getOrElse(throw new Exception("Path parameter must have a name")), + description = mc.docsOpt, + definition = Some(OpenAPI.ReferenceOr.Or(JsonSchema.fromSegmentCodec(codec))), + deprecated = mc.deprecated, + style = OpenAPI.Parameter.Style.Simple, + examples = mc.examples.map { case (name, value) => + name -> OpenAPI.ReferenceOr.Or(OpenAPI.Example(segmentToJson(codec, value))) + }, + ), + ) + }.toSet + + def headerParams: Set[OpenAPI.ReferenceOr[OpenAPI.Parameter]] = + inAtoms.header + .asInstanceOf[Chunk[MetaCodec[HttpCodec.Header[Any]]]] + .map { case mc @ MetaCodec(codec, _) => + OpenAPI.ReferenceOr.Or( + OpenAPI.Parameter.headerParameter( + name = mc.name.getOrElse(codec.name), + description = mc.docsOpt, + definition = Some(OpenAPI.ReferenceOr.Or(JsonSchema.fromTextCodec(codec.textCodec))), + deprecated = mc.deprecated, + examples = mc.examples.map { case (name, value) => + name -> OpenAPI.ReferenceOr.Or(OpenAPI.Example(codec.textCodec.encode(value).toJsonAST.toOption.get)) + }, + required = mc.required, + ), + ) + } + .toSet + + def genDiscriminator(schema: Schema[_]): Option[OpenAPI.Discriminator] = { + schema match { + case enumSchema: Schema.Enum[_] => + val discriminatorName = + enumSchema.annotations.collectFirst { case zio.schema.annotation.discriminatorName(name) => name } + val noDiscriminator = enumSchema.annotations.contains(zio.schema.annotation.noDiscriminator()) + val typeMapping = enumSchema.cases.map { case_ => + val caseName = + case_.annotations.collectFirst { case zio.schema.annotation.caseName(name) => name }.getOrElse(case_.id) + // There should be no enums with cases that are not records with a nominal id + // TODO: not true. Since one could build a schema with a enum with a case that is a primitive + val typeId = + case_.schema + .asInstanceOf[Schema.Record[_]] + .id + .asInstanceOf[TypeId.Nominal] + caseName -> schemaReferencePath(typeId, referenceType) + } + + if (noDiscriminator) None + else discriminatorName.map(name => OpenAPI.Discriminator(name, typeMapping.toMap)) + + case _ => None + } + } + + def components = OpenAPI.Components( + schemas = componentSchemas, + responses = Map.empty, + parameters = Map.empty, + examples = Map.empty, + requestBodies = Map.empty, + headers = Map.empty, + securitySchemes = Map.empty, + links = Map.empty, + callbacks = Map.empty, + ) + + @tailrec + def segmentToJson(codec: SegmentCodec[_], value: Any): Json = { + codec match { + case SegmentCodec.Empty => throw new Exception("Empty segment not allowed") + case SegmentCodec.Literal(_) => throw new Exception("Literal segment not allowed") + case SegmentCodec.BoolSeg(_) => Json.Bool(value.asInstanceOf[Boolean]) + case SegmentCodec.IntSeg(_) => Json.Num(value.asInstanceOf[Int]) + case SegmentCodec.LongSeg(_) => Json.Num(value.asInstanceOf[Long]) + case SegmentCodec.Text(_) => Json.Str(value.asInstanceOf[String]) + case SegmentCodec.UUID(_) => Json.Str(value.asInstanceOf[UUID].toString) + case SegmentCodec.Annotated(codec, _) => segmentToJson(codec, value) + case SegmentCodec.Trailing => throw new Exception("Trailing segment not allowed") + } + } + + def componentSchemas: Map[OpenAPI.Key, OpenAPI.ReferenceOr[JsonSchema]] = + (endpoint.input.alternatives.map(_._1).map(AtomizedMetaCodecs.flatten(_)).flatMap(_.content) + ++ endpoint.error.alternatives.map(_._1).map(AtomizedMetaCodecs.flatten(_)).flatMap(_.content) + ++ endpoint.output.alternatives.map(_._1).map(AtomizedMetaCodecs.flatten(_)).flatMap(_.content)).collect { + case MetaCodec(HttpCodec.Content(schema, _, _, _), _) if nominal(schema, referenceType).isDefined => + val schemas = JsonSchema.fromZSchemaMulti(schema, referenceType) + schemas.children.map { case (key, schema) => + OpenAPI.Key.fromString(key.replace("#/components/schemas/", "")).get -> OpenAPI.ReferenceOr.Or(schema) + } + (OpenAPI.Key.fromString(nominal(schema, referenceType).get).get -> + OpenAPI.ReferenceOr.Or(schemas.root.discriminator(genDiscriminator(schema)))) + case MetaCodec(HttpCodec.ContentStream(schema, _, _, _), _) if nominal(schema, referenceType).isDefined => + val schemas = JsonSchema.fromZSchemaMulti(schema, referenceType) + schemas.children.map { case (key, schema) => + OpenAPI.Key.fromString(key.replace("#/components/schemas/", "")).get -> OpenAPI.ReferenceOr.Or(schema) + } + (OpenAPI.Key.fromString(nominal(schema, referenceType).get).get -> + OpenAPI.ReferenceOr.Or(schemas.root.discriminator(genDiscriminator(schema)))) + }.flatten.toMap + + OpenAPI( + "3.1.0", + info = OpenAPI.Info( + title = "", + description = None, + termsOfService = None, + contact = None, + license = None, + version = "", + ), + servers = Nil, + paths = path, + components = Some(components), + security = Nil, + tags = Nil, + externalDocs = None, + ) + } + + private def schemaByStatusAndMediaType( + alternatives: Chunk[HttpCodec[_, _]], + referenceType: SchemaStyle, + ): Map[OpenAPI.StatusOrDefault, Map[MediaType, (JsonSchema, AtomizedMetaCodecs)]] = { + val statusAndCodec = + alternatives.map { codec => + val statusOrDefault = + status(codec).map(OpenAPI.StatusOrDefault.StatusValue(_)).getOrElse(OpenAPI.StatusOrDefault.Default) + statusOrDefault -> (AtomizedMetaCodecs + .flatten(codec), contentAsJsonSchema(codec, referenceType = referenceType)) + } + + groupMap(statusAndCodec) { case (status, _) => status } { case (_, atomizedAndSchema) => + atomizedAndSchema + }.map { case (status, values) => + val mapped = values + .foldLeft(Chunk.empty[(MediaType, (AtomizedMetaCodecs, JsonSchema))]) { case (acc, (atomized, schema)) => + if (atomized.content.size > 1) { + acc :+ (MediaType.multipart.`form-data` -> (atomized, schema)) + } else { + val mediaType = atomized.content.headOption match { + case Some(MetaCodec(HttpCodec.Content(_, Some(mediaType), _, _), _)) => + mediaType + case Some(MetaCodec(HttpCodec.ContentStream(_, Some(mediaType), _, _), _)) => + mediaType + case Some(MetaCodec(HttpCodec.ContentStream(schema, None, _, _), _)) => + if (schema == Schema[Byte]) MediaType.application.`octet-stream` + else MediaType.application.`json` + case _ => + MediaType.application.`json` + } + acc :+ (mediaType -> (atomized, schema)) + } + } + status -> groupMap(mapped) { case (mediaType, _) => mediaType } { case (_, atomizedAndSchema) => + atomizedAndSchema + }.map { + case (mediaType, Chunk((atomized, schema))) if values.size == 1 => + mediaType -> (schema, atomized) + case (mediaType, values) => + val combinedAtomized: AtomizedMetaCodecs = values.map(_._1).reduce(_ ++ _) + val combinedContentDoc = combinedAtomized.contentDocs.toCommonMark + val alternativesSchema = { + JsonSchema + .AnyOfSchema(values.map { case (_, schema) => + schema.description match { + case Some(value) => schema.description(value.replace(combinedContentDoc, "")) + case None => schema + } + }) + .minify + .description(combinedContentDoc) + } + mediaType -> (alternativesSchema, combinedAtomized) + } + } + } + + def nominal(schema: Schema[_], referenceType: SchemaStyle): Option[String] = + schema match { + case enumSchema: Schema.Enum[_] => + enumSchema.id match { + case TypeId.Structural => + None + case nominal: TypeId.Nominal if referenceType == SchemaStyle.Compact => + Some(nominal.typeName) + case nominal: TypeId.Nominal => + Some(nominal.fullyQualified.replace(".", "_")) + } + case record: Record[_] => + record.id match { + case TypeId.Structural => + None + case nominal: TypeId.Nominal if referenceType == SchemaStyle.Compact => + Some(nominal.typeName) + case nominal: TypeId.Nominal => + Some(nominal.fullyQualified.replace(".", "_")) + } + case _ => None + } + + private def responsesForAlternatives( + codecs: Map[OpenAPI.StatusOrDefault, Map[MediaType, (JsonSchema, AtomizedMetaCodecs)]], + ): Map[OpenAPI.StatusOrDefault, OpenAPI.ReferenceOr[OpenAPI.Response]] = + codecs.map { case (status, mediaTypes) => + val combinedAtomizedCodecs = mediaTypes.map { case (_, (_, atomized)) => atomized }.reduce(_ ++ _) + val mediaTypeResponses = mediaTypes.map { case (mediaType, (schema, atomized)) => + mediaType.fullType -> OpenAPI.MediaType( + schema = OpenAPI.ReferenceOr.Or(schema), + examples = atomized.contentExamples, + encoding = Map.empty, + ) + } + status -> OpenAPI.ReferenceOr.Or( + OpenAPI.Response( + headers = headersFrom(combinedAtomizedCodecs), + content = mediaTypeResponses, + links = Map.empty, + ), + ) + } + + private def headersFrom(codec: AtomizedMetaCodecs) = { + codec.header.map { case mc @ MetaCodec(codec, _) => + codec.name -> OpenAPI.ReferenceOr.Or( + OpenAPI.Header( + description = mc.docsOpt, + required = true, + deprecated = mc.deprecated, + allowEmptyValue = false, + schema = Some(JsonSchema.fromTextCodec(codec.textCodec)), + ), + ) + }.toMap + } + private def schemaReferencePath(nominal: TypeId.Nominal, referenceType: SchemaStyle): String = { + referenceType match { + case SchemaStyle.Compact => s"#/components/schemas/${nominal.typeName}}" + case _ => s"#/components/schemas/${nominal.fullyQualified.replace(".", "_")}}" + } + } +} diff --git a/zio-http/src/test/scala/zio/http/endpoint/openapi/JsonRendererSpec.scala b/zio-http/src/test/scala/zio/http/endpoint/openapi/JsonRendererSpec.scala deleted file mode 100644 index c5dc49f1b9..0000000000 --- a/zio-http/src/test/scala/zio/http/endpoint/openapi/JsonRendererSpec.scala +++ /dev/null @@ -1,191 +0,0 @@ -/* - * Copyright 2021 - 2023 Sporta Technologies PVT LTD & the ZIO HTTP contributors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package zio.http.endpoint.openapi - -import java.net.URI - -import scala.util.Try - -import zio.test._ - -import zio.http.codec.Doc -import zio.http.endpoint.openapi.OpenAPI.Parameter.{Definition, QueryParameter} -import zio.http.endpoint.openapi.OpenAPI.Schema.ResponseSchema -import zio.http.endpoint.openapi.OpenAPI.SecurityScheme.ApiKey -import zio.http.endpoint.openapi.OpenAPI.{Info, Operation, PathItem} -import zio.http.{Status, ZIOHttpSpec} - -object JsonRendererSpec extends ZIOHttpSpec { - case object Html - override def spec = - suite("JsonRenderer")( - test("render numbers") { - val rendered = - JsonRenderer.renderFields("int" -> 1, "double" -> 1.0d, "float" -> 1.0f, "long" -> 1L) - val expected = """{"int":1,"double":1.0,"float":1.0,"long":1}""" - assertTrue(rendered == expected) - }, - test("render strings") { - val rendered = JsonRenderer.renderFields("string" -> "string") - val expected = """{"string":"string"}""" - assertTrue(rendered == expected) - }, - test("render booleans") { - val rendered = JsonRenderer.renderFields("boolean" -> true) - val expected = """{"boolean":true}""" - assertTrue(rendered == expected) - }, - test("render tuples") { - val rendered = JsonRenderer.renderFields(("tuple", (1, "string"))) - val expected = """{"tuple":{"1":"string"}}""" - assertTrue(rendered == expected) - }, - test("render list") { - val rendered = JsonRenderer.renderFields("array" -> List(1, 2, 3)) - val expected = """{"array":[1,2,3]}""" - assertTrue(rendered == expected) - }, - test("render map") { - val rendered = - JsonRenderer.renderFields("map" -> Map("key" -> "value"), "otherMap" -> Map(1 -> "value")) - val expected = """{"map":{"key":"value"},"otherMap":{"1":"value"}}""" - assertTrue(rendered == expected) - }, - test("render In") { - val rendered = JsonRenderer.renderFields("type" -> ApiKey.In.Query) - val expected = """{"type":"query"}""" - assertTrue(rendered == expected) - }, - test("render empty doc") { - val rendered = JsonRenderer.renderFields("doc" -> Doc.empty) - val expected = """{"doc":""}""" - assertTrue(rendered == expected) - }, - test("render doc") { - val rendered = JsonRenderer.renderFields("doc" -> Doc.p(Doc.Span.link(new URI("https://google.com")))) - val expected = """{"doc":"W2h0dHBzOi8vZ29vZ2xlLmNvbV0oaHR0cHM6Ly9nb29nbGUuY29tKQoK"}""" - assertTrue(rendered == expected) - }, - test("render LiteralOrExpression") { - val rendered = JsonRenderer.renderFields( - "string" -> (OpenAPI.LiteralOrExpression.StringLiteral("string"): OpenAPI.LiteralOrExpression), - "number" -> (OpenAPI.LiteralOrExpression.NumberLiteral(1): OpenAPI.LiteralOrExpression), - "decimal" -> (OpenAPI.LiteralOrExpression.DecimalLiteral(1.0): OpenAPI.LiteralOrExpression), - "boolean" -> (OpenAPI.LiteralOrExpression.BooleanLiteral(true): OpenAPI.LiteralOrExpression), - "expression" -> OpenAPI.LiteralOrExpression.expression("expression"), - ) - val expected = """{"string":"string","number":1,"decimal":1.0,"boolean":true,"expression":"expression"}""" - assertTrue(rendered == expected) - }, - test("throw exception for duplicate keys") { - val rendered = Try(JsonRenderer.renderFields("key" -> 1, "key" -> 2)) - assertTrue(rendered.failed.toOption.exists(_.isInstanceOf[IllegalArgumentException])) - }, - test("render OpenAPI") { - val rendered = - OpenAPI - .OpenAPI( - info = Info( - title = "title", - version = "version", - description = Doc.p("description"), - termsOfService = new URI("https://google.com"), - contact = None, - license = None, - ), - servers = List(OpenAPI.Server(new URI("https://google.com"), Doc.p("description"), Map.empty)), - paths = Map( - OpenAPI.Path.fromString("/test").get -> PathItem( - get = Some( - Operation( - responses = Map( - Status.Ok -> OpenAPI.Response( - description = Doc.p(Doc.Span.text("description")), - content = Map( - "application/json" -> OpenAPI.MediaType( - schema = ResponseSchema( - discriminator = None, - xml = None, - externalDocs = new URI("https://google.com"), - example = "Example", - ), - examples = Map.empty, - encoding = Map.empty, - ), - ), - headers = Map.empty, - links = Map.empty, - ), - ), - tags = List("tag"), - summary = "summary", - description = Doc.p("description"), - externalDocs = Some(OpenAPI.ExternalDoc(None, new URI("https://google.com"))), - operationId = Some("operationId"), - parameters = Set( - QueryParameter( - "name", - Doc.p("description"), - definition = Definition.Content("key", "mediaType"), - examples = Map.empty, - ), - ), - servers = List(OpenAPI.Server(new URI("https://google.com"), Doc.p("description"), Map.empty)), - requestBody = None, - callbacks = Map.empty, - security = List.empty, - ), - ), - ref = "ref", - description = Doc.p("description"), - put = None, - post = None, - delete = None, - options = None, - head = None, - patch = None, - trace = None, - servers = List.empty, - parameters = Set.empty, - ), - ), - components = Some( - OpenAPI.Components( - schemas = Map.empty, - responses = Map.empty, - parameters = Map.empty, - examples = Map.empty, - requestBodies = Map.empty, - headers = Map.empty, - securitySchemes = Map.empty, - links = Map.empty, - callbacks = Map.empty, - ), - ), - security = List.empty, - tags = List.empty, - externalDocs = Some(OpenAPI.ExternalDoc(None, new URI("https://google.com"))), - openapi = "3.0.0", - ) - .toJson - - val expected = - """{"openapi":"3.0.0","info":{"title":"title","description":"ZGVzY3JpcHRpb24KCg==","termsOfService":"https://google.com","version":"version"},"servers":[{"url":"https://google.com","description":"ZGVzY3JpcHRpb24KCg==","variables":{}}],"paths":{"/test":{"$ref":"ref","summary":"","description":"ZGVzY3JpcHRpb24KCg==","get":{"tags":["tag"],"summary":"summary","description":"ZGVzY3JpcHRpb24KCg==","externalDocs":{"url":"https://google.com"},"operationId":"operationId","parameters":[{"name":"name","in":"query","description":"ZGVzY3JpcHRpb24KCg==","required":true,"deprecated":false,"allowEmptyValue":false,"definition":{"key":"key","mediaType":"mediaType"},"explode":true,"examples":{}}],"responses":{"200":{"description":"ZGVzY3JpcHRpb24KCg==","headers":{},"content":{"application/json":{"schema":{"nullable":false,"readOnly":true,"writeOnly":false,"externalDocs":"https://google.com","example":"Example","deprecated":false},"examples":{},"encoding":{}}},"links":{}}},"callbacks":{},"deprecated":false,"security":[],"servers":[{"url":"https://google.com","description":"ZGVzY3JpcHRpb24KCg==","variables":{}}]},"servers":[],"parameters":[]}},"components":{"schemas":{},"responses":{},"parameters":{},"examples":{},"requestBodies":{},"headers":{},"securitySchemes":{},"links":{},"callbacks":{}},"security":[],"tags":[],"externalDocs":{"url":"https://google.com"}}""" - assertTrue(rendered == expected) - }, - ) -} diff --git a/zio-http/src/test/scala/zio/http/endpoint/openapi/OpenAPIGenSpec.scala b/zio-http/src/test/scala/zio/http/endpoint/openapi/OpenAPIGenSpec.scala new file mode 100644 index 0000000000..7c0b25db2f --- /dev/null +++ b/zio-http/src/test/scala/zio/http/endpoint/openapi/OpenAPIGenSpec.scala @@ -0,0 +1,2233 @@ +package zio.http.endpoint.openapi + +import zio.Scope +import zio.json.ast.Json +import zio.json.{EncoderOps, JsonEncoder} +import zio.test._ + +import zio.schema.annotation.{caseName, discriminatorName, noDiscriminator, optionalField, transientField} +import zio.schema.codec.JsonCodec +import zio.schema.{DeriveSchema, Schema} + +import zio.http.Method.GET +import zio.http._ +import zio.http.codec.{Doc, HttpCodec, QueryCodec} +import zio.http.endpoint._ + +object OpenAPIGenSpec extends ZIOSpecDefault { + + final case class SimpleInputBody(name: String, age: Int) + implicit val simpleInputBodySchema: Schema[SimpleInputBody] = + DeriveSchema.gen[SimpleInputBody] + final case class OtherSimpleInputBody(fullName: String, shoeSize: Int) + implicit val otherSimpleInputBodySchema: Schema[OtherSimpleInputBody] = + DeriveSchema.gen[OtherSimpleInputBody] + final case class SimpleOutputBody(userName: String, score: Int) + implicit val simpleOutputBodySchema: Schema[SimpleOutputBody] = + DeriveSchema.gen[SimpleOutputBody] + final case class NotFoundError(message: String) + implicit val notFoundErrorSchema: Schema[NotFoundError] = + DeriveSchema.gen[NotFoundError] + final case class ImageMetadata(name: String, size: Int) + implicit val imageMetadataSchema: Schema[ImageMetadata] = + DeriveSchema.gen[ImageMetadata] + + final case class WithTransientField(name: String, @transientField age: Int) + implicit val withTransientFieldSchema: Schema[WithTransientField] = + DeriveSchema.gen[WithTransientField] + + final case class WithDefaultValue(age: Int = 42) + implicit val withDefaultValueSchema: Schema[WithDefaultValue] = + DeriveSchema.gen[WithDefaultValue] + final case class WithComplexDefaultValue(data: ImageMetadata = ImageMetadata("default", 42)) + implicit val withDefaultComplexValueSchema: Schema[WithComplexDefaultValue] = + DeriveSchema.gen[WithComplexDefaultValue] + + final case class WithOptionalField(name: String, @optionalField age: Int) + implicit val withOptionalFieldSchema: Schema[WithOptionalField] = + DeriveSchema.gen[WithOptionalField] + + sealed trait SimpleEnum + object SimpleEnum { + implicit val schema: Schema[SimpleEnum] = DeriveSchema.gen[SimpleEnum] + case object One extends SimpleEnum + case object Two extends SimpleEnum + case object Three extends SimpleEnum + } + + sealed trait SealedTraitDefaultDiscriminator + + object SealedTraitDefaultDiscriminator { + implicit val schema: Schema[SealedTraitDefaultDiscriminator] = + DeriveSchema.gen[SealedTraitDefaultDiscriminator] + + case object One extends SealedTraitDefaultDiscriminator + + case class Two(name: String) extends SealedTraitDefaultDiscriminator + + @caseName("three") + case class Three(name: String) extends SealedTraitDefaultDiscriminator + } + + @discriminatorName("type") + sealed trait SealedTraitCustomDiscriminator + + object SealedTraitCustomDiscriminator { + implicit val schema: Schema[SealedTraitCustomDiscriminator] = DeriveSchema.gen[SealedTraitCustomDiscriminator] + + case object One extends SealedTraitCustomDiscriminator + + case class Two(name: String) extends SealedTraitCustomDiscriminator + + @caseName("three") + case class Three(name: String) extends SealedTraitCustomDiscriminator + } + + @noDiscriminator + sealed trait SealedTraitNoDiscriminator + + object SealedTraitNoDiscriminator { + implicit val schema: Schema[SealedTraitNoDiscriminator] = DeriveSchema.gen[SealedTraitNoDiscriminator] + + case object One extends SealedTraitNoDiscriminator + + case class Two(name: String) extends SealedTraitNoDiscriminator + + @caseName("three") + case class Three(name: String) extends SealedTraitNoDiscriminator + } + + @noDiscriminator + sealed trait SimpleNestedSealedTrait + + object SimpleNestedSealedTrait { + implicit val schema: Schema[SimpleNestedSealedTrait] = DeriveSchema.gen[SimpleNestedSealedTrait] + + case object NestedOne extends SimpleNestedSealedTrait + + case class NestedTwo(name: SealedTraitNoDiscriminator) extends SimpleNestedSealedTrait + + case class NestedThree(name: String) extends SimpleNestedSealedTrait + } + + private val simpleEndpoint = + Endpoint( + (GET / "static" / int("id") / uuid("uuid") ?? Doc.p("user id") / string("name")) ?? Doc.p("get path"), + ) + .in[SimpleInputBody](Doc.p("input body")) + .out[SimpleOutputBody](Doc.p("output body")) + .outError[NotFoundError](Status.NotFound, Doc.p("not found")) + + private val queryParamEndpoint = + Endpoint(GET / "withQuery") + .in[SimpleInputBody] + .query(QueryCodec.paramStr("query")) + .out[SimpleOutputBody] + .outError[NotFoundError](Status.NotFound) + + private val alternativeInputEndpoint = + Endpoint(GET / "inputAlternative") + .inCodec( + (HttpCodec.content[OtherSimpleInputBody] ?? Doc.p("other input") | HttpCodec + .content[SimpleInputBody] ?? Doc.p("simple input")) ?? Doc.p("takes either of the two input bodies"), + ) + .out[SimpleOutputBody] + .outError[NotFoundError](Status.NotFound) + + def toJsonAst(str: String): Json = + Json.decoder.decodeJson(str).toOption.get + + def toJsonAst(api: OpenAPI): Json = + toJsonAst(api.toJson) + + override def spec: Spec[TestEnvironment with Scope, Any] = + suite("OpenAPIGenSpec")( + test("simple endpoint to OpenAPI") { + val generated = OpenAPIGen.fromEndpoints("Simple Endpoint", "1.0", simpleEndpoint) + val json = toJsonAst(generated) + val expectedJson = """{ + | "openapi" : "3.1.0", + | "info" : { + | "title" : "Simple Endpoint", + | "version" : "1.0" + | }, + | "paths" : { + | "/static/{id}/{uuid}/{name}" : { + | "get" : { + | "parameters" : [ + | + | { + | "name" : "id", + | "in" : "path", + | "required" : true, + | "deprecated" : false, + | "schema" : + | { + | "type" : + | "integer", + | "format" : "int32" + | }, + | "explode" : false, + | "style" : "simple" + | }, + | + | { + | "name" : "uuid", + | "in" : "path", + | "description" : "user id\n\n", + | "required" : true, + | "deprecated" : false, + | "schema" : + | { + | "type" : + | "string" + | }, + | "explode" : false, + | "style" : "simple" + | }, + | + | { + | "name" : "name", + | "in" : "path", + | "required" : true, + | "deprecated" : false, + | "schema" : + | { + | "type" : + | "string" + | }, + | "explode" : false, + | "style" : "simple" + | } + | ], + | "requestBody" : + | { + | "content" : { + | "application/json" : { + | "schema" : { + | "$ref": "#/components/schemas/SimpleInputBody", + | "description" : "input body\n\n" + | } + | } + | }, + | "required" : true + | }, + | "responses" : { + | "200" : + | { + | "description" : "", + | "content" : { + | "application/json" : { + | "schema" : { + | "$ref": "#/components/schemas/SimpleOutputBody", + | "description" : "output body\n\n" + | } + | } + | } + | }, + | "404" : + | { + | "description" : "", + | "content" : { + | "application/json" : { + | "schema" : { + | "$ref": "#/components/schemas/NotFoundError", + | "description" : "not found\n\n" + | } + | } + | } + | } + | }, + | "deprecated" : false + | } + | } + | }, + | "components" : { + | "schemas" : { + | "SimpleInputBody" : + | { + | "type" : + | "object", + | "properties" : { + | "name" : { + | "type" : + | "string" + | }, + | "age" : { + | "type" : + | "integer", + | "format" : "int32" + | } + | }, + | "additionalProperties" : + | true, + | "required" : [ + | "name", + | "age" + | ] + | }, + | "NotFoundError" : + | { + | "type" : + | "object", + | "properties" : { + | "message" : { + | "type" : + | "string" + | } + | }, + | "additionalProperties" : + | true, + | "required" : [ + | "message" + | ] + | }, + | "SimpleOutputBody" : + | { + | "type" : + | "object", + | "properties" : { + | "userName" : { + | "type" : + | "string" + | }, + | "score" : { + | "type" : + | "integer", + | "format" : "int32" + | } + | }, + | "additionalProperties" : + | true, + | "required" : [ + | "userName", + | "score" + | ] + | } + | } + | } + |}""".stripMargin + assertTrue(json == toJsonAst(expectedJson)) + }, + test("with query parameter") { + val generated = OpenAPIGen.fromEndpoints("Simple Endpoint", "1.0", queryParamEndpoint) + val json = toJsonAst(generated) + val expectedJson = """{ + | "openapi" : "3.1.0", + | "info" : { + | "title" : "Simple Endpoint", + | "version" : "1.0" + | }, + | "paths" : { + | "/withQuery" : { + | "get" : { + | "parameters" : [ + | { + | "name" : "query", + | "in" : "query", + | "required" : true, + | "deprecated" : false, + | "schema" : + | { + | "type" : + | "string" + | }, + | "explode" : false, + | "allowReserved" : false, + | "style" : "form" + | } + | ], + | "requestBody" : + | { + | "content" : { + | "application/json" : { + | "schema" : {"$ref": "#/components/schemas/SimpleInputBody"} + | } + | }, + | "required" : true + | }, + | "responses" : { + | "200" : + | { + | "description" : "", + | "content" : { + | "application/json" : { + | "schema" : {"$ref": "#/components/schemas/SimpleOutputBody"} + | } + | } + | }, + | "404" : + | { + | "description" : "", + | "content" : { + | "application/json" : { + | "schema" : {"$ref": "#/components/schemas/NotFoundError"} + | } + | } + | } + | }, + | "deprecated" : false + | } + | } + | }, + | "components" : { + | "schemas" : { + | "SimpleInputBody" : + | { + | "type" : + | "object", + | "properties" : { + | "name" : { + | "type" : + | "string" + | }, + | "age" : { + | "type" : + | "integer", + | "format" : "int32" + | } + | }, + | "additionalProperties" : + | true, + | "required" : [ + | "name", + | "age" + | ] + | }, + | "NotFoundError" : + | { + | "type" : + | "object", + | "properties" : { + | "message" : { + | "type" : + | "string" + | } + | }, + | "additionalProperties" : + | true, + | "required" : [ + | "message" + | ] + | }, + | "SimpleOutputBody" : + | { + | "type" : + | "object", + | "properties" : { + | "userName" : { + | "type" : + | "string" + | }, + | "score" : { + | "type" : + | "integer", + | "format" : "int32" + | } + | }, + | "additionalProperties" : + | true, + | "required" : [ + | "userName", + | "score" + | ] + | } + | } + | } + |}""".stripMargin + assertTrue(json == toJsonAst(expectedJson)) + }, + test("alternative input") { + val generated = OpenAPIGen.fromEndpoints("Simple Endpoint", "1.0", alternativeInputEndpoint) + val json = toJsonAst(generated) + val expectedJson = + """{ + | "openapi" : "3.1.0", + | "info" : { + | "title" : "Simple Endpoint", + | "version" : "1.0" + | }, + | "paths" : { + | "/inputAlternative" : { + | "get" : { + | "requestBody" : + | { + | "content" : { + | "application/json" : { + | "schema" : { + | "anyOf" : [ + | { + | "$ref": "#/components/schemas/OtherSimpleInputBody", + | "description" : "other input\n\n" + | }, + | { + | "$ref": "#/components/schemas/SimpleInputBody", + | "description" : "simple input\n\n" + | } + | ], + | "description" : "takes either of the two input bodies\n\n" + | } + | } + | }, + | "required" : true + | }, + | "responses" : { + | "200" : + | { + | "description" : "", + | "content" : { + | "application/json" : { + | "schema" : {"$ref": "#/components/schemas/SimpleOutputBody"} + | } + | } + | }, + | "404" : + | { + | "description" : "", + | "content" : { + | "application/json" : { + | "schema" : {"$ref": "#/components/schemas/NotFoundError"} + | } + | } + | } + | }, + | "deprecated" : false + | } + | } + | }, + | "components" : { + | "schemas" : { + | "OtherSimpleInputBody" : + | { + | "type" : + | "object", + | "properties" : { + | "fullName" : { + | "type" : + | "string" + | }, + | "shoeSize" : { + | "type" : + | "integer", + | "format" : "int32" + | } + | }, + | "additionalProperties" : + | true, + | "required" : [ + | "fullName", + | "shoeSize" + | ] + | }, + | "SimpleInputBody" : + | { + | "type" : + | "object", + | "properties" : { + | "name" : { + | "type" : + | "string" + | }, + | "age" : { + | "type" : + | "integer", + | "format" : "int32" + | } + | }, + | "additionalProperties" : + | true, + | "required" : [ + | "name", + | "age" + | ] + | }, + | "NotFoundError" : + | { + | "type" : + | "object", + | "properties" : { + | "message" : { + | "type" : + | "string" + | } + | }, + | "additionalProperties" : + | true, + | "required" : [ + | "message" + | ] + | }, + | "SimpleOutputBody" : + | { + | "type" : + | "object", + | "properties" : { + | "userName" : { + | "type" : + | "string" + | }, + | "score" : { + | "type" : + | "integer", + | "format" : "int32" + | } + | }, + | "additionalProperties" : + | true, + | "required" : [ + | "userName", + | "score" + | ] + | } + | } + | } + |}""".stripMargin + assertTrue(json == toJsonAst(expectedJson)) + }, + test("alternative output") { + val endpoint = + Endpoint(GET / "static") + .in[SimpleInputBody] + .outCodec( + (HttpCodec.content[SimpleOutputBody] ?? Doc.p("simple output") | HttpCodec + .content[NotFoundError] ?? Doc.p("not found")) ?? Doc.p("alternative outputs"), + ) + val generated = OpenAPIGen.fromEndpoints("Simple Endpoint", "1.0", endpoint) + val json = toJsonAst(generated) + val expectedJson = + """{ + | "openapi" : "3.1.0", + | "info" : { + | "title" : "Simple Endpoint", + | "version" : "1.0" + | }, + | "paths" : { + | "/static" : { + | "get" : { + | "requestBody" : + | { + | "content" : { + | "application/json" : { + | "schema" : {"$ref": "#/components/schemas/SimpleInputBody"} + | } + | }, + | "required" : true + | }, + | "responses" : { + | "default" : + | { + | "description" : "", + | "content" : { + | "application/json" : { + | "schema" : { "anyOf" : [ + | { + | "$ref": "#/components/schemas/SimpleOutputBody", + | "description" : "simple output\n\n" + | }, + | { + | "$ref": "#/components/schemas/NotFoundError", + | "description" : "not found\n\n" + | } + | ], + | "description" : "alternative outputs\n\n" + | } + | } + | } + | } + | }, + | "deprecated" : false + | } + | } + | }, + | "components" : { + | "schemas" : { + | "SimpleInputBody" : + | { + | "type" : + | "object", + | "properties" : { + | "name" : { + | "type" : + | "string" + | }, + | "age" : { + | "type" : + | "integer", + | "format" : "int32" + | } + | }, + | "additionalProperties" : + | true, + | "required" : [ + | "name", + | "age" + | ] + | }, + | "SimpleOutputBody" : + | { + | "type" : + | "object", + | "properties" : { + | "userName" : { + | "type" : + | "string" + | }, + | "score" : { + | "type" : + | "integer", + | "format" : "int32" + | } + | }, + | "additionalProperties" : + | true, + | "required" : [ + | "userName", + | "score" + | ] + | }, + | "NotFoundError" : + | { + | "type" : + | "object", + | "properties" : { + | "message" : { + | "type" : + | "string" + | } + | }, + | "additionalProperties" : + | true, + | "required" : [ + | "message" + | ] + | } + | } + | } + |}""".stripMargin + assertTrue(json == toJsonAst(expectedJson)) + }, + test("with examples") { + val endpoint = + Endpoint(GET / "static") + .inCodec( + HttpCodec + .content[SimpleInputBody] + .examples("john" -> SimpleInputBody("John", 42), "jane" -> SimpleInputBody("Jane", 43)), + ) + .outCodec( + HttpCodec + .content[SimpleOutputBody] + .examples("john" -> SimpleOutputBody("John", 42), "jane" -> SimpleOutputBody("Jane", 43)) | + HttpCodec + .content[NotFoundError] + .examples("not found" -> NotFoundError("not found")), + ) + + val generated = OpenAPIGen.fromEndpoints("Simple Endpoint", "1.0", endpoint) + val json = toJsonAst(generated) + val expectedJson = + """{ + | "openapi" : "3.1.0", + | "info" : { + | "title" : "Simple Endpoint", + | "version" : "1.0" + | }, + | "paths" : { + | "/static" : { + | "get" : { + | "requestBody" : + | { + | "content" : { + | "application/json" : { + | "schema" : + | { + | "$ref" : "#/components/schemas/SimpleInputBody" + | }, + | "examples" : { + | "john" : + | { + | "value" : { + | "name" : "John", + | "age" : 42 + | } + | }, + | "jane" : + | { + | "value" : { + | "name" : "Jane", + | "age" : 43 + | } + | } + | } + | } + | }, + | "required" : true + | }, + | "responses" : { + | "default" : + | { + | "description" : "", + | "content" : { + | "application/json" : { + | "schema" : + | { + | "anyOf" : [ + | { + | "$ref" : "#/components/schemas/SimpleOutputBody" + | }, + | { + | "$ref" : "#/components/schemas/NotFoundError" + | } + | ], + | "description" : "" + | }, + | "examples" : { + | "john" : + | { + | "value" : { + | "userName" : "John", + | "score" : 42 + | } + | }, + | "jane" : + | { + | "value" : { + | "userName" : "Jane", + | "score" : 43 + | } + | }, + | "not found" : + | { + | "value" : { + | "message" : "not found" + | } + | } + | } + | } + | } + | } + | }, + | "deprecated" : false + | } + | } + | }, + | "components" : { + | "schemas" : { + | "SimpleInputBody" : + | { + | "type" : + | "object", + | "properties" : { + | "name" : { + | "type" : + | "string" + | }, + | "age" : { + | "type" : + | "integer", + | "format" : "int32" + | } + | }, + | "additionalProperties" : + | true, + | "required" : [ + | "name", + | "age" + | ] + | }, + | "SimpleOutputBody" : + | { + | "type" : + | "object", + | "properties" : { + | "userName" : { + | "type" : + | "string" + | }, + | "score" : { + | "type" : + | "integer", + | "format" : "int32" + | } + | }, + | "additionalProperties" : + | true, + | "required" : [ + | "userName", + | "score" + | ] + | }, + | "NotFoundError" : + | { + | "type" : + | "object", + | "properties" : { + | "message" : { + | "type" : + | "string" + | } + | }, + | "additionalProperties" : + | true, + | "required" : [ + | "message" + | ] + | } + | } + | } + |}""".stripMargin + assertTrue(json == toJsonAst(expectedJson)) + }, + test("with query parameter, alternative input, alternative output and examples") { + val endpoint = + Endpoint(GET / "static") + .inCodec( + HttpCodec + .content[OtherSimpleInputBody] ?? Doc.p("other input") | + HttpCodec + .content[SimpleInputBody] ?? Doc.p("simple input"), + ) + .query(QueryCodec.paramStr("query")) + .outCodec( + HttpCodec + .content[SimpleOutputBody] ?? Doc.p("simple output") | + HttpCodec + .content[NotFoundError] ?? Doc.p("not found"), + ) + + val generated = OpenAPIGen.fromEndpoints("Simple Endpoint", "1.0", endpoint) + val json = toJsonAst(generated) + val expectedJson = + """{ + | "openapi" : "3.1.0", + | "info" : { + | "title" : "Simple Endpoint", + | "version" : "1.0" + | }, + | "paths" : { + | "/static" : { + | "get" : { + | "parameters" : [ + | + | { + | "name" : "query", + | "in" : "query", + | "required" : true, + | "deprecated" : false, + | "schema" : + | { + | "type" : + | "string" + | }, + | "explode" : false, + | "allowReserved" : false, + | "style" : "form" + | } + | ], + | "requestBody" : + | { + | "content" : { + | "application/json" : { + | "schema" : + | { + | "anyOf" : [ + | { + | "$ref" : "#/components/schemas/OtherSimpleInputBody", + | "description" : "other input\n\n" + | }, + | { + | "$ref" : "#/components/schemas/SimpleInputBody", + | "description" : "simple input\n\n" + | } + | ], + | "description" : "" + | } + | } + | }, + | "required" : true + | }, + | "responses" : { + | "default" : + | { + | "description" : "", + | "content" : { + | "application/json" : { + | "schema" : + | { + | "anyOf" : [ + | { + | "$ref" : "#/components/schemas/SimpleOutputBody", + | "description" : "simple output\n\n" + | }, + | { + | "$ref" : "#/components/schemas/NotFoundError", + | "description" : "not found\n\n" + | } + | ], + | "description" : "" + | } + | } + | } + | } + | }, + | "deprecated" : false + | } + | } + | }, + | "components" : { + | "schemas" : { + | "OtherSimpleInputBody" : + | { + | "type" : + | "object", + | "properties" : { + | "fullName" : { + | "type" : + | "string" + | }, + | "shoeSize" : { + | "type" : + | "integer", + | "format" : "int32" + | } + | }, + | "additionalProperties" : + | true, + | "required" : [ + | "fullName", + | "shoeSize" + | ] + | }, + | "SimpleInputBody" : + | { + | "type" : + | "object", + | "properties" : { + | "name" : { + | "type" : + | "string" + | }, + | "age" : { + | "type" : + | "integer", + | "format" : "int32" + | } + | }, + | "additionalProperties" : + | true, + | "required" : [ + | "name", + | "age" + | ] + | }, + | "SimpleOutputBody" : + | { + | "type" : + | "object", + | "properties" : { + | "userName" : { + | "type" : + | "string" + | }, + | "score" : { + | "type" : + | "integer", + | "format" : "int32" + | } + | }, + | "additionalProperties" : + | true, + | "required" : [ + | "userName", + | "score" + | ] + | }, + | "NotFoundError" : + | { + | "type" : + | "object", + | "properties" : { + | "message" : { + | "type" : + | "string" + | } + | }, + | "additionalProperties" : + | true, + | "required" : [ + | "message" + | ] + | } + | } + | } + |}""".stripMargin + assertTrue(json == toJsonAst(expectedJson)) + }, + test("multipart") { + val endpoint = Endpoint(GET / "test-form") + .outCodec( + (HttpCodec.contentStream[Byte]("image", MediaType.image.png) ++ + HttpCodec.content[String]("title").optional) ?? Doc.p("Test doc") ++ + HttpCodec.content[Int]("width") ++ + HttpCodec.content[Int]("height") ++ + HttpCodec.content[ImageMetadata]("metadata"), + ) + val generated = OpenAPIGen.fromEndpoints("Simple Endpoint", "1.0", endpoint) + val json = toJsonAst(generated) + val expected = """{ + | "openapi" : "3.1.0", + | "info" : { + | "title" : "Simple Endpoint", + | "version" : "1.0" + | }, + | "paths" : { + | "/test-form" : { + | "get" : { + | "requestBody" : + | { + | "content" : { + | "application/json" : { + | "schema" : + | { + | "type" : + | "null" + | } + | } + | }, + | "required" : false + | }, + | "responses" : { + | "default" : + | { + | "description" : "", + | "content" : { + | "multipart/form-data" : { + | "schema" : + | { + | "type" : + | "object", + | "properties" : { + | "image" : { + | "type" : + | "string", + | "contentEncoding" : "binary", + | "contentMediaType" : "application/octet-stream" + | }, + | "height" : { + | "type" : + | "integer", + | "format" : "int32" + | }, + | "metadata" : { + | "$ref" : "#/components/schemas/ImageMetadata" + | }, + | "title" : { + | "type" : + | [ + | "string", + | "null" + | ] + | }, + | "width" : { + | "type" : + | "integer", + | "format" : "int32" + | } + | }, + | "additionalProperties" : + | false, + | "required" : [ + | "image", + | "width", + | "height", + | "metadata" + | ], + | "description" : "Test doc\n\n" + | } + | } + | } + | } + | }, + | "deprecated" : false + | } + | } + | }, + | "components" : { + | "schemas" : { + | "ImageMetadata" : + | { + | "type" : + | "object", + | "properties" : { + | "name" : { + | "type" : + | "string" + | }, + | "size" : { + | "type" : + | "integer", + | "format" : "int32" + | } + | }, + | "additionalProperties" : + | true, + | "required" : [ + | "name", + | "size" + | ] + | } + | } + | } + |}""".stripMargin + assertTrue(json == toJsonAst(expected)) + }, + test("multiple endpoint definitions") { + val generated = + OpenAPIGen.fromEndpoints( + "Simple Endpoint", + "1.0", + simpleEndpoint, + queryParamEndpoint, + alternativeInputEndpoint, + ) + val json = toJsonAst(generated) + val expected = + """{ + | "openapi" : "3.1.0", + | "info" : { + | "title" : "Simple Endpoint", + | "version" : "1.0" + | }, + | "paths" : { + | "/static/{id}/{uuid}/{name}" : { + | "get" : { + | "parameters" : [ + | + | { + | "name" : "id", + | "in" : "path", + | "required" : true, + | "deprecated" : false, + | "schema" : + | { + | "type" : + | "integer", + | "format" : "int32" + | }, + | "explode" : false, + | "style" : "simple" + | }, + | + | { + | "name" : "uuid", + | "in" : "path", + | "description" : "user id\n\n", + | "required" : true, + | "deprecated" : false, + | "schema" : + | { + | "type" : + | "string" + | }, + | "explode" : false, + | "style" : "simple" + | }, + | + | { + | "name" : "name", + | "in" : "path", + | "required" : true, + | "deprecated" : false, + | "schema" : + | { + | "type" : + | "string" + | }, + | "explode" : false, + | "style" : "simple" + | } + | ], + | "requestBody" : + | { + | "content" : { + | "application/json" : { + | "schema" : + | { + | "$ref" : "#/components/schemas/SimpleInputBody", + | "description" : "input body\n\n" + | } + | } + | }, + | "required" : true + | }, + | "responses" : { + | "200" : + | { + | "description" : "", + | "content" : { + | "application/json" : { + | "schema" : + | { + | "$ref" : "#/components/schemas/SimpleOutputBody", + | "description" : "output body\n\n" + | } + | } + | } + | }, + | "404" : + | { + | "description" : "", + | "content" : { + | "application/json" : { + | "schema" : + | { + | "$ref" : "#/components/schemas/NotFoundError", + | "description" : "not found\n\n" + | } + | } + | } + | } + | }, + | "deprecated" : false + | } + | }, + | "/withQuery" : { + | "get" : { + | "parameters" : [ + | + | { + | "name" : "query", + | "in" : "query", + | "required" : true, + | "deprecated" : false, + | "schema" : + | { + | "type" : + | "string" + | }, + | "explode" : false, + | "allowReserved" : false, + | "style" : "form" + | } + | ], + | "requestBody" : + | { + | "content" : { + | "application/json" : { + | "schema" : + | { + | "$ref" : "#/components/schemas/SimpleInputBody" + | } + | } + | }, + | "required" : true + | }, + | "responses" : { + | "200" : + | { + | "description" : "", + | "content" : { + | "application/json" : { + | "schema" : + | { + | "$ref" : "#/components/schemas/SimpleOutputBody" + | } + | } + | } + | }, + | "404" : + | { + | "description" : "", + | "content" : { + | "application/json" : { + | "schema" : + | { + | "$ref" : "#/components/schemas/NotFoundError" + | } + | } + | } + | } + | }, + | "deprecated" : false + | } + | }, + | "/inputAlternative" : { + | "get" : { + | "requestBody" : + | { + | "content" : { + | "application/json" : { + | "schema" : + | { + | "anyOf" : [ + | { + | "$ref" : "#/components/schemas/OtherSimpleInputBody", + | "description" : "other input\n\n" + | }, + | { + | "$ref" : "#/components/schemas/SimpleInputBody", + | "description" : "simple input\n\n" + | } + | ], + | "description" : "takes either of the two input bodies\n\n" + | } + | } + | }, + | "required" : true + | }, + | "responses" : { + | "200" : + | { + | "description" : "", + | "content" : { + | "application/json" : { + | "schema" : + | { + | "$ref" : "#/components/schemas/SimpleOutputBody" + | } + | } + | } + | }, + | "404" : + | { + | "description" : "", + | "content" : { + | "application/json" : { + | "schema" : + | { + | "$ref" : "#/components/schemas/NotFoundError" + | } + | } + | } + | } + | }, + | "deprecated" : false + | } + | } + | }, + | "components" : { + | "schemas" : { + | "SimpleInputBody" : + | { + | "type" : + | "object", + | "properties" : { + | "name" : { + | "type" : + | "string" + | }, + | "age" : { + | "type" : + | "integer", + | "format" : "int32" + | } + | }, + | "additionalProperties" : + | true, + | "required" : [ + | "name", + | "age" + | ] + | }, + | "NotFoundError" : + | { + | "type" : + | "object", + | "properties" : { + | "message" : { + | "type" : + | "string" + | } + | }, + | "additionalProperties" : + | true, + | "required" : [ + | "message" + | ] + | }, + | "SimpleOutputBody" : + | { + | "type" : + | "object", + | "properties" : { + | "userName" : { + | "type" : + | "string" + | }, + | "score" : { + | "type" : + | "integer", + | "format" : "int32" + | } + | }, + | "additionalProperties" : + | true, + | "required" : [ + | "userName", + | "score" + | ] + | }, + | "OtherSimpleInputBody" : + | { + | "type" : + | "object", + | "properties" : { + | "fullName" : { + | "type" : + | "string" + | }, + | "shoeSize" : { + | "type" : + | "integer", + | "format" : "int32" + | } + | }, + | "additionalProperties" : + | true, + | "required" : [ + | "fullName", + | "shoeSize" + | ] + | } + | } + | } + |}""".stripMargin + assertTrue(json == toJsonAst(expected)) + }, + test("transient field") { + val endpoint = Endpoint(GET / "static").in[WithTransientField] + val generated = OpenAPIGen.fromEndpoints("Simple Endpoint", "1.0", endpoint) + val json = toJsonAst(generated) + val expected = """{ + | "openapi" : "3.1.0", + | "info" : { + | "title" : "Simple Endpoint", + | "version" : "1.0" + | }, + | "paths" : { + | "/static" : { + | "get" : { + | "requestBody" : + | { + | "content" : { + | "application/json" : { + | "schema" : + | { + | "$ref" : "#/components/schemas/WithTransientField" + | } + | } + | }, + | "required" : true + | }, + | "deprecated" : false + | } + | } + | }, + | "components" : { + | "schemas" : { + | "WithTransientField" : + | { + | "type" : + | "object", + | "properties" : { + | "name" : { + | "type" : + | "string" + | } + | }, + | "additionalProperties" : + | true, + | "required" : [ + | "name" + | ] + | } + | } + | } + |}""".stripMargin + assertTrue(json == toJsonAst(expected)) + }, + test("primitive default value") { + val endpoint = Endpoint(GET / "static").in[WithDefaultValue] + val generated = OpenAPIGen.fromEndpoints("Simple Endpoint", "1.0", endpoint) + val json = toJsonAst(generated) + val expected = + """{ + | "openapi" : "3.1.0", + | "info" : { + | "title" : "Simple Endpoint", + | "version" : "1.0" + | }, + | "paths" : { + | "/static" : { + | "get" : { + | "requestBody" : + | { + | "content" : { + | "application/json" : { + | "schema" : + | { + | "$ref" : "#/components/schemas/WithDefaultValue" + | } + | } + | }, + | "required" : true + | }, + | "deprecated" : false + | } + | } + | }, + | "components" : { + | "schemas" : { + | "WithDefaultValue" : + | { + | "type" : + | "object", + | "properties" : { + | "age" : { + | "type" : + | "integer", + | "format" : "int32", + | "description" : "If not set, this field defaults to the value of the default annotation.", + | "default" : 42 + | } + | }, + | "additionalProperties" : + | true + | } + | } + | } + |}""".stripMargin + assertTrue(json == toJsonAst(expected)) + }, + test("complex default value") { + val endpoint = Endpoint(GET / "static").in[WithComplexDefaultValue] + val generated = OpenAPIGen.fromEndpoints("Simple Endpoint", "1.0", endpoint) + val json = toJsonAst(generated) + val expected = + """{ + | "openapi" : "3.1.0", + | "info" : { + | "title" : "Simple Endpoint", + | "version" : "1.0" + | }, + | "paths" : { + | "/static" : { + | "get" : { + | "requestBody" : + | { + | "content" : { + | "application/json" : { + | "schema" : + | { + | "$ref" : "#/components/schemas/WithComplexDefaultValue" + | } + | } + | }, + | "required" : true + | }, + | "deprecated" : false + | } + | } + | }, + | "components" : { + | "schemas" : { + | "WithComplexDefaultValue" : + | { + | "type" : + | "object", + | "properties" : { + | "data" : { + | "type" : + | "object", + | "properties" : { + | "name" : { + | "type" : + | "string" + | }, + | "size" : { + | "type" : + | "integer", + | "format" : "int32" + | } + | }, + | "additionalProperties" : + | true, + | "required" : [ + | "name", + | "size" + | ], + | "description" : "If not set, this field defaults to the value of the default annotation.", + | "default" : { + | "name" : "default", + | "size" : 42 + | } + | } + | }, + | "additionalProperties" : + | true + | } + | } + | } + |}""".stripMargin + assertTrue(json == toJsonAst(expected)) + }, + test("optional field") { + val endpoint = Endpoint(GET / "static").in[WithOptionalField] + val generated = OpenAPIGen.fromEndpoints("Simple Endpoint", "1.0", endpoint) + val json = toJsonAst(generated) + val expected = """{ + | "openapi" : "3.1.0", + | "info" : { + | "title" : "Simple Endpoint", + | "version" : "1.0" + | }, + | "paths" : { + | "/static" : { + | "get" : { + | "requestBody" : + | { + | "content" : { + | "application/json" : { + | "schema" : + | { + | "$ref" : "#/components/schemas/WithOptionalField" + | } + | } + | }, + | "required" : true + | }, + | "deprecated" : false + | } + | } + | }, + | "components" : { + | "schemas" : { + | "WithOptionalField" : + | { + | "type" : + | "object", + | "properties" : { + | "name" : { + | "type" : + | "string" + | }, + | "age" : { + | "type" : + | "integer", + | "format" : "int32" + | } + | }, + | "additionalProperties" : + | true, + | "required" : [ + | "name" + | ] + | } + | } + | } + |}""".stripMargin + assertTrue(json == toJsonAst(expected)) + }, + test("enum") { + val endpoint = Endpoint(GET / "static").in[SimpleEnum] + val generated = OpenAPIGen.fromEndpoints("Simple Endpoint", "1.0", endpoint) + val json = toJsonAst(generated) + val expected = """{ + | "openapi" : "3.1.0", + | "info" : { + | "title" : "Simple Endpoint", + | "version" : "1.0" + | }, + | "paths" : { + | "/static" : { + | "get" : { + | "requestBody" : + | { + | "content" : { + | "application/json" : { + | "schema" : + | { + | "$ref" : "#/components/schemas/SimpleEnum" + | } + | } + | }, + | "required" : true + | }, + | "deprecated" : false + | } + | } + | }, + | "components" : { + | "schemas" : { + | "SimpleEnum" : + | { + | "type" : + | "string", + | "enumValues" : [ + | "One", + | "Two", + | "Three" + | ] + | } + | } + | } + |}""".stripMargin + assertTrue(json == toJsonAst(expected)) + }, + test("sealed trait default discriminator") { + val endpoint = Endpoint(GET / "static").in[SealedTraitDefaultDiscriminator] + val generated = OpenAPIGen.fromEndpoints("Simple Endpoint", "1.0", endpoint) + val json = toJsonAst(generated) + val expectedJson = + """{ + | "openapi" : "3.1.0", + | "info" : { + | "title" : "Simple Endpoint", + | "version" : "1.0" + | }, + | "paths" : { + | "/static" : { + | "get" : { + | "requestBody" : + | { + | "content" : { + | "application/json" : { + | "schema" : + | { + | "$ref" : "#/components/schemas/SealedTraitDefaultDiscriminator" + | } + | } + | }, + | "required" : true + | }, + | "deprecated" : false + | } + | } + | }, + | "components" : { + | "schemas" : { + | "One" : + | { + | "type" : + | "object", + | "properties" : {}, + | "additionalProperties" : + | true + | }, + | "Two" : + | { + | "type" : + | "object", + | "properties" : { + | "name" : { + | "type" : + | "string" + | } + | }, + | "additionalProperties" : + | true, + | "required" : [ + | "name" + | ] + | }, + | "Three" : + | { + | "type" : + | "object", + | "properties" : { + | "name" : { + | "type" : + | "string" + | } + | }, + | "additionalProperties" : + | true, + | "required" : [ + | "name" + | ] + | }, + | "SealedTraitDefaultDiscriminator" : + | { + | "oneOf" : [ + | { + | "type" : + | "object", + | "properties" : { + | "One" : { + | "$ref" : "#/components/schemas/One" + | } + | }, + | "additionalProperties" : + | false, + | "required" : [ + | "One" + | ] + | }, + | { + | "type" : + | "object", + | "properties" : { + | "Two" : { + | "$ref" : "#/components/schemas/Two" + | } + | }, + | "additionalProperties" : + | false, + | "required" : [ + | "Two" + | ] + | }, + | { + | "type" : + | "object", + | "properties" : { + | "three" : { + | "$ref" : "#/components/schemas/Three" + | } + | }, + | "additionalProperties" : + | false, + | "required" : [ + | "three" + | ] + | } + | ] + | } + | } + | } + |}""".stripMargin + assertTrue(json == toJsonAst(expectedJson)) + }, + test("sealed trait custom discriminator") { + val endpoint = Endpoint(GET / "static").in[SealedTraitCustomDiscriminator] + val generated = OpenAPIGen.fromEndpoints("Simple Endpoint", "1.0", endpoint) + val json = toJsonAst(generated) + val expectedJson = + """{ + | "openapi" : "3.1.0", + | "info" : { + | "title" : "Simple Endpoint", + | "version" : "1.0" + | }, + | "paths" : { + | "/static" : { + | "get" : { + | "requestBody" : + | { + | "content" : { + | "application/json" : { + | "schema" : + | { + | "$ref" : "#/components/schemas/SealedTraitCustomDiscriminator" + | } + | } + | }, + | "required" : true + | }, + | "deprecated" : false + | } + | } + | }, + | "components" : { + | "schemas" : { + | "One" : + | { + | "type" : + | "object", + | "properties" : {}, + | "additionalProperties" : + | true + | }, + | "Two" : + | { + | "type" : + | "object", + | "properties" : { + | "name" : { + | "type" : + | "string" + | } + | }, + | "additionalProperties" : + | true, + | "required" : [ + | "name" + | ] + | }, + | "Three" : + | { + | "type" : + | "object", + | "properties" : { + | "name" : { + | "type" : + | "string" + | } + | }, + | "additionalProperties" : + | true, + | "required" : [ + | "name" + | ] + | }, + | "SealedTraitCustomDiscriminator" : + | { + | "oneOf" : [ + | { + | "$ref" : "#/components/schemas/One" + | }, + | { + | "$ref" : "#/components/schemas/Two" + | }, + | { + | "$ref" : "#/components/schemas/Three" + | } + | ], + | "discriminator" : { + | "propertyName" : "type", + | "mapping" : { + | "One" : "#/components/schemas/One}", + | "Two" : "#/components/schemas/Two}", + | "three" : "#/components/schemas/Three}" + | } + | } + | } + | } + | } + |}""".stripMargin + assertTrue(json == toJsonAst(expectedJson)) + }, + test("sealed trait no discriminator") { + val endpoint = Endpoint(GET / "static").in[SealedTraitNoDiscriminator] + val generated = OpenAPIGen.fromEndpoints("Simple Endpoint", "1.0", endpoint) + val json = toJsonAst(generated) + val expected = """{ + | "openapi" : "3.1.0", + | "info" : { + | "title" : "Simple Endpoint", + | "version" : "1.0" + | }, + | "paths" : { + | "/static" : { + | "get" : { + | "requestBody" : + | { + | "content" : { + | "application/json" : { + | "schema" : + | { + | "$ref" : "#/components/schemas/SealedTraitNoDiscriminator" + | } + | } + | }, + | "required" : true + | }, + | "deprecated" : false + | } + | } + | }, + | "components" : { + | "schemas" : { + | "One" : + | { + | "type" : + | "object", + | "properties" : {}, + | "additionalProperties" : + | true + | }, + | "Two" : + | { + | "type" : + | "object", + | "properties" : { + | "name" : { + | "type" : + | "string" + | } + | }, + | "additionalProperties" : + | true, + | "required" : [ + | "name" + | ] + | }, + | "Three" : + | { + | "type" : + | "object", + | "properties" : { + | "name" : { + | "type" : + | "string" + | } + | }, + | "additionalProperties" : + | true, + | "required" : [ + | "name" + | ] + | }, + | "SealedTraitNoDiscriminator" : + | { + | "oneOf" : [ + | { + | "$ref" : "#/components/schemas/One" + | }, + | { + | "$ref" : "#/components/schemas/Two" + | }, + | { + | "$ref" : "#/components/schemas/Three" + | } + | ] + | } + | } + | } + |} + |""".stripMargin + assertTrue(json == toJsonAst(expected)) + }, + test("sealed trait with nested sealed trait") { + val endpoint = Endpoint(GET / "static").in[SimpleNestedSealedTrait] + val generated = OpenAPIGen.fromEndpoints("Simple Endpoint", "1.0", endpoint) + val json = toJsonAst(generated) + val expectedJson = + """{ + | "openapi" : "3.1.0", + | "info" : { + | "title" : "Simple Endpoint", + | "version" : "1.0" + | }, + | "paths" : { + | "/static" : { + | "get" : { + | "requestBody" : + | { + | "content" : { + | "application/json" : { + | "schema" : + | { + | "$ref" : "#/components/schemas/SimpleNestedSealedTrait" + | } + | } + | }, + | "required" : true + | }, + | "deprecated" : false + | } + | } + | }, + | "components" : { + | "schemas" : { + | "NestedOne" : + | { + | "type" : + | "object", + | "properties" : {}, + | "additionalProperties" : + | true + | }, + | "NestedThree" : + | { + | "type" : + | "object", + | "properties" : { + | "name" : { + | "type" : + | "string" + | } + | }, + | "additionalProperties" : + | true, + | "required" : [ + | "name" + | ] + | }, + | "NestedTwo" : + | { + | "type" : + | "object", + | "properties" : { + | "name" : { + | "oneOf" : [ + | { + | "$ref" : "#/components/schemas/One" + | }, + | { + | "$ref" : "#/components/schemas/Two" + | }, + | { + | "$ref" : "#/components/schemas/Three" + | } + | ] + | } + | }, + | "additionalProperties" : + | true, + | "required" : [ + | "name" + | ] + | }, + | "Two" : + | { + | "type" : + | "object", + | "properties" : { + | "name" : { + | "type" : + | "string" + | } + | }, + | "additionalProperties" : + | true, + | "required" : [ + | "name" + | ] + | }, + | "Three" : + | { + | "type" : + | "object", + | "properties" : { + | "name" : { + | "type" : + | "string" + | } + | }, + | "additionalProperties" : + | true, + | "required" : [ + | "name" + | ] + | }, + | "One" : + | { + | "type" : + | "object", + | "properties" : {}, + | "additionalProperties" : + | true + | }, + | "SimpleNestedSealedTrait" : + | { + | "oneOf" : [ + | { + | "$ref" : "#/components/schemas/NestedOne" + | }, + | { + | "$ref" : "#/components/schemas/NestedTwo" + | }, + | { + | "$ref" : "#/components/schemas/NestedThree" + | } + | ] + | } + | } + | } + |}""".stripMargin + assertTrue(json == toJsonAst(expectedJson)) + }, + ) + +}