diff --git a/zio-http-cli/src/main/scala/zio/http/endpoint/cli/CliEndpoint.scala b/zio-http-cli/src/main/scala/zio/http/endpoint/cli/CliEndpoint.scala index 824c3d3b3..dffbc04aa 100644 --- a/zio-http-cli/src/main/scala/zio/http/endpoint/cli/CliEndpoint.scala +++ b/zio-http-cli/src/main/scala/zio/http/endpoint/cli/CliEndpoint.scala @@ -1,15 +1,7 @@ package zio.http.endpoint.cli -import scala.util.Try - -import zio.cli._ - -import zio.schema._ - import zio.http._ -import zio.http.codec.HttpCodec.Metadata import zio.http.codec._ -import zio.http.codec.internal._ import zio.http.endpoint._ /** @@ -90,36 +82,34 @@ private[cli] object CliEndpoint { def fromCodec[Input](input: HttpCodec[_, Input]): CliEndpoint = { input match { - case atom: HttpCodec.Atom[_, _] => fromAtom(atom) - case HttpCodec.TransformOrFail(api, _, _) => fromCodec(api) - case HttpCodec.Annotated(in, metadata) => + case atom: HttpCodec.Atom[_, _] => fromAtom(atom) + case HttpCodec.TransformOrFail(api, _, _) => fromCodec(api) + case HttpCodec.Annotated(in, metadata) => metadata match { case HttpCodec.Metadata.Documented(doc) => fromCodec(in) describeOptions doc case _ => fromCodec(in) } - case HttpCodec.Fallback(left, right, _) => fromCodec(left) ++ fromCodec(right) - case HttpCodec.Combine(left, right, _) => fromCodec(left) ++ fromCodec(right) - case _ => CliEndpoint.empty + case HttpCodec.Fallback(left, right, _, _) => fromCodec(left) ++ fromCodec(right) + case HttpCodec.Combine(left, right, _) => fromCodec(left) ++ fromCodec(right) + case _ => CliEndpoint.empty } } private def fromAtom[Input](input: HttpCodec.Atom[_, Input]): CliEndpoint = { input match { - case HttpCodec.Content(schema, mediaType, nameOption, _) => { + case HttpCodec.Content(schema, mediaType, nameOption, _) => val name = nameOption match { case Some(x) => x case None => "" } CliEndpoint(body = HttpOptions.Body(name, mediaType, schema) :: List()) - } - case HttpCodec.ContentStream(schema, mediaType, nameOption, _) => { + case HttpCodec.ContentStream(schema, mediaType, nameOption, _) => val name = nameOption match { case Some(x) => x case None => "" } CliEndpoint(body = HttpOptions.Body(name, mediaType, schema) :: List()) - } case HttpCodec.Header(name, textCodec, _) => CliEndpoint(headers = HttpOptions.Header(name, textCodec) :: List()) 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 2d855e3c1..b3463ca81 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 @@ -312,10 +312,9 @@ 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) => + case SegmentCodec.UUID(name) => Options .text(name) .mapOrFail(str => @@ -327,14 +326,13 @@ private[cli] object HttpOptions { }, ) .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) + 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) } 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 53334db6c..ec5b4f7bf 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 @@ -20,18 +20,16 @@ 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) => (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) + 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 => ("", "") } fromSegment(segment) diff --git a/zio-http/jvm/src/test/resources/endpoint/openapi/multiple-methods-on-same-path.json b/zio-http/jvm/src/test/resources/endpoint/openapi/multiple-methods-on-same-path.json index 1fc50f437..8ec0699ba 100644 --- a/zio-http/jvm/src/test/resources/endpoint/openapi/multiple-methods-on-same-path.json +++ b/zio-http/jvm/src/test/resources/endpoint/openapi/multiple-methods-on-same-path.json @@ -19,7 +19,6 @@ }, "responses": { "200": { - "description": "", "content": { "text/plain": { "schema": { @@ -44,7 +43,6 @@ }, "responses": { "201": { - "description": "", "content": { "text/plain": { "schema": { diff --git a/zio-http/jvm/src/test/scala/zio/http/endpoint/openapi/OpenAPIGenSpec.scala b/zio-http/jvm/src/test/scala/zio/http/endpoint/openapi/OpenAPIGenSpec.scala index e56a571cb..119a3e35e 100644 --- a/zio-http/jvm/src/test/scala/zio/http/endpoint/openapi/OpenAPIGenSpec.scala +++ b/zio-http/jvm/src/test/scala/zio/http/endpoint/openapi/OpenAPIGenSpec.scala @@ -1,17 +1,16 @@ package zio.http.endpoint.openapi import zio.json.ast.Json -import zio.json.{EncoderOps, JsonEncoder} import zio.test._ import zio.{Scope, ZIO} import zio.schema.annotation.{caseName, discriminatorName, noDiscriminator, optionalField, transientField} -import zio.schema.codec.JsonCodec import zio.schema.{DeriveSchema, Schema} import zio.http.Method.{GET, POST} import zio.http._ -import zio.http.codec.{Doc, HttpCodec, QueryCodec} +import zio.http.codec.PathCodec.string +import zio.http.codec.{ContentCodec, Doc, HttpCodec, QueryCodec} import zio.http.endpoint._ object OpenAPIGenSpec extends ZIOSpecDefault { @@ -114,6 +113,12 @@ object OpenAPIGenSpec extends ZIOSpecDefault { case class NestedThree(name: String) extends SimpleNestedSealedTrait } + case class Payload(content: String) + + object Payload { + implicit val schema: Schema[Payload] = DeriveSchema.gen[Payload] + } + private val simpleEndpoint = Endpoint( (GET / "static" / int("id") / uuid("uuid") ?? Doc.p("user id") / string("name")) ?? Doc.p("get path"), @@ -158,6 +163,7 @@ object OpenAPIGenSpec extends ZIOSpecDefault { | "paths" : { | "/static/{id}/{uuid}/{name}" : { | "get" : { + | "description" : "get path\n\n", | "parameters" : [ | | { @@ -183,7 +189,8 @@ object OpenAPIGenSpec extends ZIOSpecDefault { | "deprecated" : false, | "schema" : | { - | "type" : "string", + | "type" : + | "string", | "format" : "uuid" | }, | "explode" : false, @@ -208,9 +215,10 @@ object OpenAPIGenSpec extends ZIOSpecDefault { | { | "content" : { | "application/json" : { - | "schema" : { - | "$ref": "#/components/schemas/SimpleInputBody", - | "description" : "input body\n\n" + | "schema" : + | { + | "$ref" : "#/components/schemas/SimpleInputBody", + | "description" : "input body\n\n" | } | } | }, @@ -219,32 +227,32 @@ object OpenAPIGenSpec extends ZIOSpecDefault { | "responses" : { | "200" : | { - | "description" : "", | "content" : { | "application/json" : { - | "schema" : { - | "$ref": "#/components/schemas/SimpleOutputBody", - | "description" : "output body\n\n" + | "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" + | "schema" : + | { + | "$ref" : "#/components/schemas/NotFoundError", + | "description" : "not found\n\n" + | } | } | } | } - | } - | }, - | "deprecated" : false + | }, + | "deprecated" : false + | } | } - | } | }, | "components" : { | "schemas" : { @@ -353,7 +361,6 @@ object OpenAPIGenSpec extends ZIOSpecDefault { | "responses" : { | "200" : | { - | "description" : "", | "content" : { | "application/json" : { | "schema" : {"$ref": "#/components/schemas/SimpleOutputBody"} @@ -362,7 +369,6 @@ object OpenAPIGenSpec extends ZIOSpecDefault { | }, | "404" : | { - | "description" : "", | "content" : { | "application/json" : { | "schema" : {"$ref": "#/components/schemas/NotFoundError"} @@ -478,7 +484,6 @@ object OpenAPIGenSpec extends ZIOSpecDefault { | "responses" : { | "200" : | { - | "description" : "", | "content" : { | "application/json" : { | "schema" : {"$ref": "#/components/schemas/SimpleOutputBody"} @@ -487,7 +492,6 @@ object OpenAPIGenSpec extends ZIOSpecDefault { | }, | "404" : | { - | "description" : "", | "content" : { | "application/json" : { | "schema" : {"$ref": "#/components/schemas/NotFoundError"} @@ -620,7 +624,6 @@ object OpenAPIGenSpec extends ZIOSpecDefault { | "responses" : { | "default" : | { - | "description" : "", | "content" : { | "application/json" : { | "schema" : { "anyOf" : [ @@ -770,7 +773,6 @@ object OpenAPIGenSpec extends ZIOSpecDefault { | "responses" : { | "default" : | { - | "description" : "", | "content" : { | "application/json" : { | "schema" : @@ -953,7 +955,6 @@ object OpenAPIGenSpec extends ZIOSpecDefault { | "responses" : { | "default" : | { - | "description" : "", | "content" : { | "application/json" : { | "schema" : @@ -1071,7 +1072,7 @@ object OpenAPIGenSpec extends ZIOSpecDefault { 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[String]("title").optional ?? Doc.p("Test doc")) ++ HttpCodec.content[Int]("width") ++ HttpCodec.content[Int]("height") ++ HttpCodec.content[ImageMetadata]("metadata"), @@ -1103,7 +1104,6 @@ object OpenAPIGenSpec extends ZIOSpecDefault { | "responses" : { | "default" : | { - | "description" : "", | "content" : { | "multipart/form-data" : { | "schema" : @@ -1130,7 +1130,8 @@ object OpenAPIGenSpec extends ZIOSpecDefault { | [ | "string", | "null" - | ] + | ], + | "description" : "Test doc\n\n" | }, | "width" : { | "type" : @@ -1205,6 +1206,7 @@ object OpenAPIGenSpec extends ZIOSpecDefault { | "paths" : { | "/static/{id}/{uuid}/{name}" : { | "get" : { + | "description": "get path\n\n", | "parameters" : [ | | { @@ -1267,7 +1269,6 @@ object OpenAPIGenSpec extends ZIOSpecDefault { | "responses" : { | "200" : | { - | "description" : "", | "content" : { | "application/json" : { | "schema" : @@ -1280,7 +1281,6 @@ object OpenAPIGenSpec extends ZIOSpecDefault { | }, | "404" : | { - | "description" : "", | "content" : { | "application/json" : { | "schema" : @@ -1329,7 +1329,6 @@ object OpenAPIGenSpec extends ZIOSpecDefault { | "responses" : { | "200" : | { - | "description" : "", | "content" : { | "application/json" : { | "schema" : @@ -1341,7 +1340,6 @@ object OpenAPIGenSpec extends ZIOSpecDefault { | }, | "404" : | { - | "description" : "", | "content" : { | "application/json" : { | "schema" : @@ -1382,7 +1380,6 @@ object OpenAPIGenSpec extends ZIOSpecDefault { | "responses" : { | "200" : | { - | "description" : "", | "content" : { | "application/json" : { | "schema" : @@ -1394,7 +1391,6 @@ object OpenAPIGenSpec extends ZIOSpecDefault { | }, | "404" : | { - | "description" : "", | "content" : { | "application/json" : { | "schema" : @@ -2357,6 +2353,220 @@ object OpenAPIGenSpec extends ZIOSpecDefault { )(buf => ZIO.attemptBlockingIO(buf.close()).orDie)(buf => ZIO.attemptBlockingIO(buf.mkString)) } yield assertTrue(json == toJsonAst(expectedJson)) }, + test("examples for combined input") { + val endpoint = + Endpoint(Method.GET / "root" / string("name")) + .in[Payload] + .out[String] + .examplesIn("hi" -> ("name_value", Payload("input"))) + + val openApi = + OpenAPIGen.fromEndpoints( + title = "Combined input examples", + version = "1.0", + endpoint, + ) + val json = toJsonAst(openApi) + val expectedJson = """{ + | "openapi": "3.1.0", + | "info": { + | "title": "Combined input examples", + | "version": "1.0" + | }, + | "paths": { + | "/root/{name}": { + | "get": { + | "parameters": [ + | { + | "name": "name", + | "in": "path", + | "required": true, + | "deprecated": false, + | "schema": { + | "type": "string" + | }, + | "explode": false, + | "examples": { + | "hi": { + | "value": "name_value" + | } + | }, + | "style": "simple" + | } + | ], + | "requestBody": { + | "content": { + | "application/json": { + | "schema": { + | "$ref": "#/components/schemas/Payload" + | }, + | "examples": { + | "hi": { + | "value": { + | "content": "input" + | } + | } + | } + | } + | }, + | "required": true + | }, + | "responses": { + | "200": { + | "content": { + | "application/json": { + | "schema": { + | "type": "string" + | } + | } + | } + | } + | }, + | "deprecated": false + | } + | } + | }, + | "components": { + | "schemas": { + | "Payload": { + | "type": "object", + | "properties": { + | "content": { + | "type": "string" + | } + | }, + | "additionalProperties": true, + | "required": [ + | "content" + | ] + | } + | } + | } + |}""".stripMargin + assertTrue(json == toJsonAst(expectedJson)) + }, + test("example for alternated input") { + val endpoint = + Endpoint(Method.GET / "root" / string("name")) + .inCodec(ContentCodec.content[Payload] | ContentCodec.content[String]) + .out[String] + .examplesIn("hi" -> ("name_value", Left(Payload("input"))), "ho" -> ("name_value2", Right("input"))) + val openApi = + OpenAPIGen.fromEndpoints( + title = "Alternated input examples", + version = "1.0", + endpoint, + ) + val json = toJsonAst(openApi) + val expectedJson = """{ + | "openapi" : "3.1.0", + | "info" : { + | "title" : "Alternated input examples", + | "version" : "1.0" + | }, + | "paths" : { + | "/root/{name}" : { + | "get" : { + | "parameters" : [ + | + | { + | "name" : "name", + | "in" : "path", + | "required" : true, + | "deprecated" : false, + | "schema" : + | { + | "type" : + | "string" + | }, + | "explode" : false, + | "examples" : { + | "hi" : + | { + | "value" : "name_value" + | }, + | "ho" : + | { + | "value" : "name_value2" + | } + | }, + | "style" : "simple" + | } + | ], + | "requestBody" : + | { + | "content" : { + | "application/json" : { + | "schema" : + | { + | "anyOf" : [ + | { + | "$ref" : "#/components/schemas/Payload" + | }, + | { + | "type" : + | "string" + | } + | ], + | "description" : "" + | }, + | "examples" : { + | "hi" : + | { + | "value" : { + | "content" : "input" + | } + | }, + | "ho" : + | { + | "value" : "input" + | } + | } + | } + | }, + | "required" : true + | }, + | "responses" : { + | "200" : + | { + | "content" : { + | "application/json" : { + | "schema" : + | { + | "type" : + | "string" + | } + | } + | } + | } + | }, + | "deprecated" : false + | } + | } + | }, + | "components" : { + | "schemas" : { + | "Payload" : + | { + | "type" : + | "object", + | "properties" : { + | "content" : { + | "type" : + | "string" + | } + | }, + | "additionalProperties" : + | true, + | "required" : [ + | "content" + | ] + | } + | } + | } + |}""".stripMargin + assertTrue(json == toJsonAst(expectedJson)) + }, ) } diff --git a/zio-http/shared/src/main/scala/zio/http/codec/HttpCodec.scala b/zio-http/shared/src/main/scala/zio/http/codec/HttpCodec.scala index 5e0822c93..d7084fd65 100644 --- a/zio-http/shared/src/main/scala/zio/http/codec/HttpCodec.scala +++ b/zio-http/shared/src/main/scala/zio/http/codec/HttpCodec.scala @@ -23,7 +23,6 @@ import scala.language.implicitConversions import scala.reflect.ClassTag import zio._ -import zio.stacktracer.TracingImplicits.disableAutoTrace import zio.stream.ZStream @@ -93,7 +92,7 @@ sealed trait HttpCodec[-AtomTypes, Value] { if (self eq HttpCodec.Halt) that.asInstanceOf[HttpCodec[AtomTypes1, alternator.Out]] else { HttpCodec - .Fallback(self, that, HttpCodec.Fallback.Condition.IsHttpCodecError) + .Fallback(self, that, alternator, HttpCodec.Fallback.Condition.IsHttpCodecError) .transform[alternator.Out](either => either.fold(alternator.left(_), alternator.right(_)))(value => alternator .unleft(value) @@ -288,7 +287,7 @@ sealed trait HttpCodec[-AtomTypes, Value] { if (self eq HttpCodec.Halt) HttpCodec.empty.asInstanceOf[HttpCodec[AtomTypes, Option[Value]]] else { HttpCodec - .Fallback(self, HttpCodec.empty, HttpCodec.Fallback.Condition.isMissingDataOnly) + .Fallback(self, HttpCodec.empty, Alternator.either, HttpCodec.Fallback.Condition.isMissingDataOnly) .transform[Option[Value]](either => either.fold(Some(_), _ => None))(_.toLeft(())) }, Metadata.Optional(), @@ -633,12 +632,21 @@ object HttpCodec extends ContentCodecs with HeaderCodecs with MethodCodecs with } } - sealed trait Metadata[Value] + sealed trait Metadata[Value] { + def transform[Value2](f: Value => Value2): Metadata[Value2] = + this match { + case Metadata.Named(name) => Metadata.Named(name) + case Metadata.Optional() => Metadata.Optional() + case Metadata.Examples(ex) => Metadata.Examples(ex.map { case (k, v) => k -> f(v) }) + case Metadata.Documented(doc) => Metadata.Documented(doc) + case Metadata.Deprecated(doc) => Metadata.Deprecated(doc) + } + } object Metadata { final case class Named[A](name: String) extends Metadata[A] - final case class Optional[A]() extends Metadata[Option[A]] + final case class Optional[A]() extends Metadata[A] final case class Examples[A](examples: Map[String, A]) extends Metadata[A] @@ -673,6 +681,7 @@ object HttpCodec extends ContentCodecs with HeaderCodecs with MethodCodecs with private[http] final case class Fallback[AtomType, A, B]( left: HttpCodec[AtomType, A], right: HttpCodec[AtomType, B], + alternator: Alternator[A, B], condition: Fallback.Condition, ) extends HttpCodec[AtomType, Either[A, B]] { type Left = A @@ -720,37 +729,102 @@ object HttpCodec extends ContentCodecs with HeaderCodecs with MethodCodecs with private[http] def flattenFallbacks[AtomTypes, A]( api: HttpCodec[AtomTypes, A], ): Chunk[(HttpCodec[AtomTypes, A], Fallback.Condition)] = { - def rewrite[T, B](api: HttpCodec[T, B]): Chunk[(HttpCodec[T, B], Fallback.Condition)] = + + def rewrite[T, B]( + api: HttpCodec[T, B], + annotations: Chunk[HttpCodec.Metadata[B]], + ): Chunk[(HttpCodec[T, B], Fallback.Condition)] = api match { - case fallback @ HttpCodec.Fallback(left, right, condition) => - rewrite[T, fallback.Left](left).map { case (codec, condition) => + case fallback @ HttpCodec.Fallback(left, right, alternator, condition) => + rewrite[T, fallback.Left](left, reduceExamplesLeft(annotations, alternator)).map { case (codec, condition) => codec.toLeft[fallback.Right] -> condition } ++ - rewrite[T, fallback.Right](right).map { case (codec, _) => + rewrite[T, fallback.Right](right, reduceExamplesRight(annotations, alternator)).map { case (codec, _) => codec.toRight[fallback.Left] -> condition } case transform @ HttpCodec.TransformOrFail(codec, f, g) => - rewrite[T, transform.In](codec).map { case (codec, condition) => + rewrite[T, transform.In]( + codec, + annotations.map(_.transform { v => + g(v) match { + case Left(error) => throw new Exception(error) + case Right(value) => value + } + }), + ).map { case (codec, condition) => HttpCodec.TransformOrFail(codec, f, g) -> condition } case combine @ HttpCodec.Combine(left, right, combiner) => for { - (l, lCondition) <- rewrite[T, combine.Left](left) - (r, rCondition) <- rewrite[T, combine.Right](right) + (l, lCondition) <- rewrite[T, combine.Left](left, reduceExamplesLeft(annotations, combiner)) + (r, rCondition) <- rewrite[T, combine.Right](right, reduceExamplesRight(annotations, combiner)) } yield HttpCodec.Combine(l, r, combiner) -> lCondition.combine(rCondition) case HttpCodec.Annotated(in, metadata) => - rewrite[T, B](in).map { case (codec, missingDataOnly) => codec.annotate(metadata) -> missingDataOnly } + rewrite[T, B](in, metadata +: annotations) case HttpCodec.Empty => Chunk.single(HttpCodec.Empty -> Fallback.Condition.IsHttpCodecError) case HttpCodec.Halt => Chunk.empty - case atom: Atom[_, _] => Chunk.single(atom -> Fallback.Condition.IsHttpCodecError) + case atom: Atom[_, _] => + Chunk.single(annotations.foldLeft[HttpCodec[T, B]](atom)(_ annotate _) -> Fallback.Condition.IsHttpCodecError) } - rewrite(api) + rewrite(api, Chunk.empty) } + + private[http] def reduceExamplesLeft[T, L, R]( + annotations: Chunk[HttpCodec.Metadata[T]], + combiner: Combiner[L, R], + ): Chunk[HttpCodec.Metadata[L]] = + annotations.map { + case HttpCodec.Metadata.Examples(examples) => + HttpCodec.Metadata.Examples(examples.map { case (name, value) => + name -> combiner.separate(value.asInstanceOf[combiner.Out])._1 + }) + case other => + other.asInstanceOf[HttpCodec.Metadata[L]] + } + + private[http] def reduceExamplesLeft[T, L, R]( + annotations: Chunk[HttpCodec.Metadata[T]], + alternator: Alternator[L, R], + ): Chunk[HttpCodec.Metadata[L]] = + annotations.map { + case HttpCodec.Metadata.Examples(examples) => + HttpCodec.Metadata.Examples(examples.flatMap { case (name, value) => + alternator.unleft(value.asInstanceOf[alternator.Out]).map(name -> _) + }) + case other => + other.asInstanceOf[HttpCodec.Metadata[L]] + } + + private[http] def reduceExamplesRight[T, L, R]( + annotations: Chunk[HttpCodec.Metadata[T]], + combiner: Combiner[L, R], + ): Chunk[HttpCodec.Metadata[R]] = + annotations.map { + case HttpCodec.Metadata.Examples(examples) => + HttpCodec.Metadata.Examples(examples.map { case (name, value) => + name -> combiner.separate(value.asInstanceOf[combiner.Out])._2 + }) + case other => + other.asInstanceOf[HttpCodec.Metadata[R]] + } + + private[http] def reduceExamplesRight[T, L, R]( + annotations: Chunk[HttpCodec.Metadata[T]], + alternator: Alternator[L, R], + ): Chunk[HttpCodec.Metadata[R]] = + annotations.map { + case HttpCodec.Metadata.Examples(examples) => + HttpCodec.Metadata.Examples(examples.flatMap { case (name, value) => + alternator.unright(value.asInstanceOf[alternator.Out]).map(name -> _) + }) + case other => + other.asInstanceOf[HttpCodec.Metadata[R]] + } } diff --git a/zio-http/shared/src/main/scala/zio/http/codec/PathCodec.scala b/zio-http/shared/src/main/scala/zio/http/codec/PathCodec.scala index dbb3fb470..a268b3eb2 100644 --- a/zio-http/shared/src/main/scala/zio/http/codec/PathCodec.scala +++ b/zio-http/shared/src/main/scala/zio/http/codec/PathCodec.scala @@ -40,7 +40,8 @@ sealed trait PathCodec[A] { self => * Attaches documentation to the path codec, which may be used when generating * developer docs for a route. */ - def ??(doc: Doc): PathCodec[A] + def ??(doc: Doc): PathCodec[A] = + self.annotate(MetaData.Documented(doc)) final def ++[B](that: PathCodec[B])(implicit combiner: Combiner[A, B]): PathCodec[combiner.Out] = PathCodec.Concat(self, that, combiner) @@ -53,6 +54,13 @@ sealed trait PathCodec[A] { self => ): Routes[Env, Response] = routes.nest(ev(self)) + final def annotate(metaData: MetaData[A]): PathCodec[A] = { + self match { + case Annotated(codc, annotations) => Annotated(codc, annotations :+ metaData) + case _ => Annotated(self, Chunk(metaData)) + } + } + final def asType[B](implicit ev: A =:= B): PathCodec[B] = self.asInstanceOf[PathCodec[B]] /** @@ -212,7 +220,14 @@ sealed trait PathCodec[A] { self => /** * Returns the documentation for the path codec, if any. */ - def doc: Doc + def doc: Doc = + self match { + case Segment(_) => Doc.empty + case TransformOrFail(api, _, _) => api.doc + case Concat(left, right, _) => left.doc + right.doc + case Annotated(codec, annotations) => + codec.doc + annotations.collectFirst { case MetaData.Documented(doc) => doc }.getOrElse(Doc.empty) + } /** * Encodes a value of type `A` into the method and path that this route @@ -222,13 +237,21 @@ sealed trait PathCodec[A] { self => private[http] final def erase: PathCodec[Any] = self.asInstanceOf[PathCodec[Any]] + final def example(name: String, example: A): PathCodec[A] = + annotate(MetaData.Examples(Map(name -> example))) + + final def examples(examples: (String, A)*): PathCodec[A] = + annotate(MetaData.Examples(examples.toMap)) + /** * Formats a value of type `A` into a path. This is useful for embedding paths * into HTML that is rendered by the server. */ final def format(value: A): Either[String, Path] = { def loop(path: PathCodec[_], value: Any): Either[String, Path] = path match { - case PathCodec.Concat(left, right, combiner, _) => + case PathCodec.Annotated(codec, _) => + loop(codec, value) + case PathCodec.Concat(left, right, combiner) => val (leftValue, rightValue) = combiner.separate(value.asInstanceOf[combiner.Out]) for { @@ -261,20 +284,21 @@ 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.Annotated(codec, _) => + loop(codec) + 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.Annotated(codec, _) => loop(PathCodec.Segment(codec)).head + 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 Concat(left, right, combiner, _) => + case Concat(left, right, combiner) => loop(left) ++ loop(right) ++ Chunk(Opt.Combine(combiner)) case TransformOrFail(api, f, _) => @@ -291,7 +315,9 @@ sealed trait PathCodec[A] { self => */ def render: String = { def loop(path: PathCodec[_]): String = path match { - case PathCodec.Concat(left, right, _, _) => + case PathCodec.Annotated(codec, _) => + loop(codec) + case PathCodec.Concat(left, right, _) => loop(left) + loop(right) case PathCodec.Segment(segment) => segment.render @@ -305,7 +331,9 @@ sealed trait PathCodec[A] { self => private[zio] def renderIgnoreTrailing: String = { def loop(path: PathCodec[_]): String = path match { - case PathCodec.Concat(left, right, _, _) => + case PathCodec.Annotated(codec, _) => + loop(codec) + case PathCodec.Concat(left, right, _) => loop(left) + loop(right) case PathCodec.Segment(SegmentCodec.Trailing) => "" @@ -323,9 +351,11 @@ sealed trait PathCodec[A] { self => */ def segments: Chunk[SegmentCodec[_]] = { def loop(path: PathCodec[_]): Chunk[SegmentCodec[_]] = path match { - case PathCodec.Segment(segment) => Chunk(segment) + case PathCodec.Annotated(codec, _) => + loop(codec) + case PathCodec.Segment(segment) => Chunk(segment) - case PathCodec.Concat(left, right, _, _) => + case PathCodec.Concat(left, right, _) => loop(left) ++ loop(right) case PathCodec.TransformOrFail(api, _, _) => @@ -388,26 +418,35 @@ object PathCodec { def uuid(name: String): PathCodec[java.util.UUID] = Segment(SegmentCodec.uuid(name)) - 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 Segment[A](segment: SegmentCodec[A]) extends PathCodec[A] + private[http] final case class Concat[A, B, C]( left: PathCodec[A], right: PathCodec[B], combiner: Combiner.WithOut[A, B, C], - doc: Doc = Doc.empty, - ) extends PathCodec[C] { - def ??(doc: Doc): Concat[A, B, C] = copy(doc = this.doc + doc) - } + ) extends PathCodec[C] private[http] final case class TransformOrFail[X, A]( api: PathCodec[X], f: X => Either[String, A], g: A => Either[String, X], ) extends PathCodec[A] { - override def ??(doc: Doc): TransformOrFail[X, A] = copy(api = api ?? doc) - override def doc: Doc = api.doc + type In = X + type Out = A + } + + final case class Annotated[A](codec: PathCodec[A], annotations: Chunk[MetaData[A]]) extends PathCodec[A] { + + override def equals(that: Any): Boolean = + codec.equals(that) + + } + + 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] val someUnit = Some(()) diff --git a/zio-http/shared/src/main/scala/zio/http/codec/SegmentCodec.scala b/zio-http/shared/src/main/scala/zio/http/codec/SegmentCodec.scala index ece07e6ba..a895610bd 100644 --- a/zio-http/shared/src/main/scala/zio/http/codec/SegmentCodec.scala +++ b/zio-http/shared/src/main/scala/zio/http/codec/SegmentCodec.scala @@ -20,7 +20,6 @@ import scala.language.implicitConversions import zio.Chunk import zio.http.Path -import zio.http.codec.SegmentCodec.{Annotated, MetaData} sealed trait SegmentCodec[A] { self => private var _hashCode: Int = 0 @@ -28,24 +27,7 @@ sealed trait SegmentCodec[A] { self => final type Type = 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 } @@ -69,15 +51,14 @@ sealed trait SegmentCodec[A] { self => final def render: String = { if (_render == "") _render = self.asInstanceOf[SegmentCodec[_]] match { - 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 + 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"/..." } _render } @@ -112,31 +93,6 @@ object SegmentCodec { def uuid(name: String): SegmentCodec[java.util.UUID] = SegmentCodec.UUID(name) - 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"") diff --git a/zio-http/shared/src/main/scala/zio/http/codec/internal/AtomizedCodecs.scala b/zio-http/shared/src/main/scala/zio/http/codec/internal/AtomizedCodecs.scala index 62fb2424f..8645d26ee 100644 --- a/zio-http/shared/src/main/scala/zio/http/codec/internal/AtomizedCodecs.scala +++ b/zio-http/shared/src/main/scala/zio/http/codec/internal/AtomizedCodecs.scala @@ -85,6 +85,6 @@ private[http] object AtomizedCodecs { case Annotated(api, _) => flattenedAtoms(api) case Empty => Chunk.empty case Halt => Chunk.empty - case Fallback(_, _, _) => throw new UnsupportedOperationException("Cannot handle fallback at this level") + case Fallback(_, _, _, _) => throw new UnsupportedOperationException("Cannot handle fallback at this level") } } diff --git a/zio-http/shared/src/main/scala/zio/http/codec/internal/Mechanic.scala b/zio-http/shared/src/main/scala/zio/http/codec/internal/Mechanic.scala index 9fe957e43..09e60dacc 100644 --- a/zio-http/shared/src/main/scala/zio/http/codec/internal/Mechanic.scala +++ b/zio-http/shared/src/main/scala/zio/http/codec/internal/Mechanic.scala @@ -42,10 +42,10 @@ private[http] object Mechanic { val (api2, resultIndices) = indexedImpl(api, indices) (TransformOrFail(api2, f, g).asInstanceOf[HttpCodec[R, A]], resultIndices) - case Annotated(api, _) => indexedImpl(api.asInstanceOf[HttpCodec[R, A]], indices) - case Empty => (Empty.asInstanceOf[HttpCodec[R, A]], indices) - case Halt => (Halt.asInstanceOf[HttpCodec[R, A]], indices) - case Fallback(_, _, _) => throw new UnsupportedOperationException("Cannot handle fallback at this level") + case Annotated(api, _) => indexedImpl(api.asInstanceOf[HttpCodec[R, A]], indices) + case Empty => (Empty.asInstanceOf[HttpCodec[R, A]], indices) + case Halt => (Halt.asInstanceOf[HttpCodec[R, A]], indices) + case Fallback(_, _, _, _) => throw new UnsupportedOperationException("Cannot handle fallback at this level") } def makeConstructor[R, A]( @@ -101,7 +101,7 @@ private[http] object Mechanic { case Halt => throw HaltException - case Fallback(_, _, _) => throw new UnsupportedOperationException("Cannot handle fallback at this level") + case Fallback(_, _, _, _) => throw new UnsupportedOperationException("Cannot handle fallback at this level") } } @@ -143,7 +143,7 @@ private[http] object Mechanic { case Halt => (_, _) => () - case Fallback(_, _, _) => throw new UnsupportedOperationException("Cannot handle fallback at this level") + case Fallback(_, _, _, _) => throw new UnsupportedOperationException("Cannot handle fallback at this level") } } } diff --git a/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/JsonSchema.scala b/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/JsonSchema.scala index 04d37a4d8..445914cb4 100644 --- a/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/JsonSchema.scala +++ b/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/JsonSchema.scala @@ -297,40 +297,16 @@ object JsonSchema { 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(JsonSchema.StringFormat.UUID) - case SegmentCodec.Annotated(codec, annotations) => - fromSegmentCodec(codec).description(segmentDoc(annotations)).examples(segmentExamples(codec, annotations)) + 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(JsonSchema.StringFormat.UUID) 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[java.lang.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 { diff --git a/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/OpenAPI.scala b/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/OpenAPI.scala index d1e964f03..e3999a83d 100644 --- a/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/OpenAPI.scala +++ b/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/OpenAPI.scala @@ -992,7 +992,7 @@ object OpenAPI { * of the names for Component Objects. */ final case class Response( - description: Doc = Doc.Empty, + description: Option[Doc] = None, headers: Map[String, ReferenceOr[Header]] = Map.empty, content: Map[String, MediaType] = Map.empty, links: Map[String, ReferenceOr[Link]] = Map.empty, diff --git a/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/OpenAPIGen.scala b/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/OpenAPIGen.scala index c2460e942..4fec54101 100644 --- a/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/OpenAPIGen.scala +++ b/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/OpenAPIGen.scala @@ -2,7 +2,6 @@ package zio.http.endpoint.openapi import java.util.UUID -import scala.annotation.tailrec import scala.collection.{immutable, mutable} import zio.Chunk @@ -40,7 +39,7 @@ object OpenAPIGen { result.built } - final case class MetaCodec[T](codec: T, annotations: Chunk[HttpCodec.Metadata[Any]]) { + final case class MetaCodec[T](codec: T, annotations: Chunk[HttpCodec.Metadata[_]]) { lazy val docs: Doc = { val annotatedDoc = annotations.foldLeft(Doc.empty) { case (doc, HttpCodec.Metadata.Documented(nextDoc)) => doc + nextDoc @@ -133,13 +132,9 @@ object OpenAPIGen { 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))) - } + mc.examples(schema) case mc @ MetaCodec(HttpCodec.ContentStream(schema, _, _, _), _) => - mc.examples.map { case (name, value) => - name -> OpenAPI.ReferenceOr.Or(OpenAPI.Example(toJsonAst(schema, value))) - } + mc.examples(schema) case _ => Map.empty[String, OpenAPI.ReferenceOr.Or[OpenAPI.Example]] }.toMap @@ -194,33 +189,75 @@ object OpenAPIGen { 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 codec @ HttpCodec.Combine(left, right, combiner) => + flattenedAtoms( + left, + HttpCodec + .reduceExamplesLeft[A, codec.Left, codec.Right]( + annotations.asInstanceOf[Chunk[HttpCodec.Metadata[A]]], + combiner, + ) + .asInstanceOf[Chunk[HttpCodec.Metadata[Any]]], + ) ++ + flattenedAtoms( + right, + HttpCodec + .reduceExamplesRight[A, codec.Left, codec.Right]( + annotations.asInstanceOf[Chunk[HttpCodec.Metadata[A]]], + combiner, + ) + .asInstanceOf[Chunk[HttpCodec.Metadata[Any]]], + ) + case path: HttpCodec.Path[_] => metaCodecFromPathCodec(path.pathCodec, annotations) + case atom: HttpCodec.Atom[_, A] => 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]]], + def metaCodecFromPathCodec( + codec: PathCodec[_], + annotations: Chunk[HttpCodec.Metadata[_]], + ): Chunk[MetaCodec[SegmentCodec[_]]] = { + def loop( + path: PathCodec[_], + annotations: Chunk[HttpCodec.Metadata[_]], + ): Chunk[(SegmentCodec[_], Chunk[HttpCodec.Metadata[_]])] = path match { + case PathCodec.Annotated(codec, newAnnotations) => + loop(codec, newAnnotations.map(toHttpCodecAnnotations) ++ annotations) + case PathCodec.Segment(segment) => Chunk(segment -> annotations) + + case PathCodec.Concat(left, right, combiner) => + loop(left, HttpCodec.reduceExamplesLeft(annotations.asInstanceOf[Chunk[HttpCodec.Metadata[Any]]], combiner)) ++ + loop(right, HttpCodec.reduceExamplesRight(annotations.asInstanceOf[Chunk[HttpCodec.Metadata[Any]]], combiner)) + + case codec @ PathCodec.TransformOrFail(api, _, g) => + loop( + api, + annotations.map(_.transform { v => + g(v.asInstanceOf[codec.Out]) match { + case Left(error) => throw new Exception(error) + case Right(value) => value + } + }), ) - case other => MetaCodec(other, Chunk.empty) } + + loop(codec, annotations).map { case (sc, annotations) => + MetaCodec(sc.asInstanceOf[SegmentCodec[_]], annotations.asInstanceOf[Chunk[HttpCodec.Metadata[Any]]]) + }.asInstanceOf[Chunk[MetaCodec[SegmentCodec[_]]]] } + def toHttpCodecAnnotations(annotation: PathCodec.MetaData[_]): HttpCodec.Metadata[_] = + annotation match { + case PathCodec.MetaData.Documented(value) => HttpCodec.Metadata.Documented(value) + case PathCodec.MetaData.Examples(examples) => HttpCodec.Metadata.Examples(examples) + } + def contentAsJsonSchema[R, A]( codec: HttpCodec[R, A], metadata: Chunk[HttpCodec.Metadata[_]] = Chunk.empty, @@ -322,7 +359,7 @@ object OpenAPIGen { 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") + case HttpCodec.Fallback(_, _, _, _) => throw new IllegalArgumentException("Fallback not supported at this point") } } @@ -357,7 +394,7 @@ object OpenAPIGen { None case HttpCodec.Combine(left, right, _) => status(left).orElse(status(right)) - case HttpCodec.Fallback(left, right, _) => + case HttpCodec.Fallback(left, right, _, _) => status(left).orElse(status(right)) case _ => None @@ -469,17 +506,17 @@ object OpenAPIGen { def buildPath(in: HttpCodec[_, _]): OpenAPI.Path = { def pathCodec(in1: HttpCodec[_, _]): Option[HttpCodec.Path[_]] = in1 match { - case atom: HttpCodec.Atom[_, _] => + 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)) + 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 = { @@ -497,11 +534,12 @@ object OpenAPIGen { .getOrElse(throw new Exception("No method specified")) } - def operation(endpoint: Endpoint[_, _, _, _, _]): OpenAPI.Operation = + def operation(endpoint: Endpoint[_, _, _, _, _]): OpenAPI.Operation = { + val maybeDoc = Some(endpoint.doc + pathDoc).filter(!_.isEmpty) OpenAPI.Operation( tags = Nil, summary = None, - description = Some(endpoint.doc + pathDoc).filter(!_.isEmpty), + description = maybeDoc, externalDocs = None, operationId = None, parameters = parameters, @@ -511,20 +549,33 @@ object OpenAPIGen { 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 pathDoc: Doc = + inAtoms.path + .flatMap(_.docsOpt) + .map(_.flattened) + .reduceOption(_ intersect _) + .flatMap(_.reduceOption(_ + _)) + .getOrElse(Doc.empty) +// { +// def loop(codec: PathCodec[_]): Doc = codec match { +// case PathCodec.Annotated(codec, annotations) => +// annotations +// .collect { case PathCodec.MetaData.Documented(doc) => doc } +// .reduceOption(_ + _) +// .getOrElse(Doc.empty) + loop(codec) +// case PathCodec.Segment(_) => +// 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(_ ++ _) @@ -575,7 +626,7 @@ object OpenAPIGen { OpenAPI.ReferenceOr.Or( OpenAPI.Parameter.pathParameter( name = mc.name.getOrElse(throw new Exception("Path parameter must have a name")), - description = mc.docsOpt, + description = mc.docsOpt.flatMap(_.flattened.filterNot(_ == pathDoc).reduceOption(_ + _)), definition = Some(OpenAPI.ReferenceOr.Or(JsonSchema.fromSegmentCodec(codec))), deprecated = mc.deprecated, style = OpenAPI.Parameter.Style.Simple, @@ -643,18 +694,16 @@ object OpenAPIGen { 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") + 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.Trailing => throw new Exception("Trailing segment not allowed") } }