From 15f06ce0f6222c21326298e8aed3d064a2242c13 Mon Sep 17 00:00:00 2001 From: Nabil Abdel-Hafeez <7283535+987Nabil@users.noreply.github.com> Date: Fri, 2 Aug 2024 17:46:31 +0200 Subject: [PATCH] Tags for Endpoint and OpenAPI generation (#2716) (#2930) --- .../zio/http/gen/scala/CodeGenSpec.scala | 2 +- .../endpoint/openapi/OpenAPIGenSpec.scala | 85 +++++++------------ .../src/main/scala/zio/http/codec/Doc.scala | 54 ++++++++++-- .../scala/zio/http/endpoint/Endpoint.scala | 24 ++++-- .../http/endpoint/openapi/OpenAPIGen.scala | 2 +- 5 files changed, 98 insertions(+), 69 deletions(-) diff --git a/zio-http-gen/src/test/scala/zio/http/gen/scala/CodeGenSpec.scala b/zio-http-gen/src/test/scala/zio/http/gen/scala/CodeGenSpec.scala index be1ce84075..1aafb2bc2e 100644 --- a/zio-http-gen/src/test/scala/zio/http/gen/scala/CodeGenSpec.scala +++ b/zio-http-gen/src/test/scala/zio/http/gen/scala/CodeGenSpec.scala @@ -8,7 +8,7 @@ import scala.meta._ import scala.meta.parsers._ import scala.util.{Failure, Success, Try} -import zio.json.JsonDecoder +import zio.json.{JsonDecoder, JsonEncoder} import zio.test.Assertion.{hasSameElements, isFailure, isSuccess, throws} import zio.test.TestAspect.{blocking, flaky} import zio.test.TestFailure.fail 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 26b26f642c..fc7d3d82a1 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 @@ -171,7 +171,7 @@ object OpenAPIGenSpec extends ZIOSpecDefault { 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 generated = OpenAPIGen.fromEndpoints("Simple Endpoint", "1.0", simpleEndpoint.tag("simple", "endpoint")) val json = toJsonAst(generated) val expectedJson = """{ | "openapi" : "3.1.0", @@ -181,55 +181,49 @@ object OpenAPIGenSpec extends ZIOSpecDefault { | }, | "paths" : { | "/static/{id}/{uuid}/{name}" : { + | "description" : "- simple\n- endpoint\n", | "get" : { + | "tags" : [ + | "simple", + | "endpoint" + | ], | "description" : "get path\n\n", | "parameters" : [ - | - | { + | { | "name" : "id", | "in" : "path", | "required" : true, - | "schema" : - | { - | "type" : - | "integer", + | "schema" : { + | "type" : "integer", | "format" : "int32" | }, | "style" : "simple" | }, - | - | { + | { | "name" : "uuid", | "in" : "path", | "description" : "user id\n\n", | "required" : true, - | "schema" : - | { - | "type" : - | "string", + | "schema" : { + | "type" : "string", | "format" : "uuid" | }, | "style" : "simple" | }, - | - | { + | { | "name" : "name", | "in" : "path", | "required" : true, - | "schema" : - | { - | "type" : - | "string" + | "schema" : { + | "type" : "string" | }, | "style" : "simple" | } | ], - | "requestBody" : - | { + | "requestBody" : { | "content" : { | "application/json" : { - | "schema" : - | { + | "schema" : { | "$ref" : "#/components/schemas/SimpleInputBody", | "description" : "input body\n\n" | } @@ -238,24 +232,20 @@ object OpenAPIGenSpec extends ZIOSpecDefault { | "required" : true | }, | "responses" : { - | "200" : - | { + | "200" : { | "content" : { | "application/json" : { - | "schema" : - | { + | "schema" : { | "$ref" : "#/components/schemas/SimpleOutputBody", | "description" : "output body\n\n" | } | } | } | }, - | "404" : - | { + | "404" : { | "content" : { | "application/json" : { - | "schema" : - | { + | "schema" : { | "$ref" : "#/components/schemas/NotFoundError", | "description" : "not found\n\n" | } @@ -268,32 +258,25 @@ object OpenAPIGenSpec extends ZIOSpecDefault { | }, | "components" : { | "schemas" : { - | "NotFoundError" : - | { - | "type" : - | "object", + | "NotFoundError" : { + | "type" : "object", | "properties" : { | "message" : { - | "type" : - | "string" + | "type" : "string" | } | }, | "required" : [ | "message" | ] | }, - | "SimpleInputBody" : - | { - | "type" : - | "object", + | "SimpleInputBody" : { + | "type" : "object", | "properties" : { | "name" : { - | "type" : - | "string" + | "type" : "string" | }, | "age" : { - | "type" : - | "integer", + | "type" : "integer", | "format" : "int32" | } | }, @@ -302,18 +285,14 @@ object OpenAPIGenSpec extends ZIOSpecDefault { | "age" | ] | }, - | "SimpleOutputBody" : - | { - | "type" : - | "object", + | "SimpleOutputBody" : { + | "type" : "object", | "properties" : { | "userName" : { - | "type" : - | "string" + | "type" : "string" | }, | "score" : { - | "type" : - | "integer", + | "type" : "integer", | "format" : "int32" | } | }, diff --git a/zio-http/shared/src/main/scala/zio/http/codec/Doc.scala b/zio-http/shared/src/main/scala/zio/http/codec/Doc.scala index 628cc59856..dd89d4890f 100644 --- a/zio-http/shared/src/main/scala/zio/http/codec/Doc.scala +++ b/zio-http/shared/src/main/scala/zio/http/codec/Doc.scala @@ -17,7 +17,6 @@ package zio.http.codec import zio.Chunk -import zio.stacktracer.TracingImplicits.disableAutoTrace import zio.schema.Schema @@ -54,6 +53,24 @@ sealed trait Doc { self => case x => Chunk(x) } + def tag(tags: Seq[String]): Doc = self match { + case Tagged(doc, existingTags) => Tagged(doc, existingTags ++ tags) + case _ => Tagged(self, tags.toList) + } + + def tag(tag: String): Doc = + self match { + case Tagged(doc, tags) => Tagged(doc, tags :+ tag) + case _ => Tagged(self, List(tag)) + } + + def tag(tag: String, tags: String*): Doc = self.tag(tag +: tags) + + def tags: List[String] = self match { + case Tagged(_, tags) => tags + case _ => Nil + } + def toCommonMark: String = { val writer = new StringBuilder @@ -131,20 +148,27 @@ sealed trait Doc { self => case Doc.Raw(_, docType) => throw new IllegalArgumentException(s"Unsupported raw doc type: $docType") + case Doc.Tagged(_, _) => + } } render(this) + val tags = self.tags + if (tags.nonEmpty) { + // Add all tags to the end of the document as an unordered list + render(Doc.unorderedListing(tags.map(Doc.p): _*)) + } writer.toString() } def toHtml: template.Html = { import template._ - self match { - case Doc.Empty => + val html: Html = self match { + case Doc.Empty => Html.Empty - case Header(value, level) => + case Header(value, level) => level match { case 1 => h1(value) case 2 => h2(value) @@ -154,9 +178,9 @@ sealed trait Doc { self => case 6 => h6(value) case _ => throw new IllegalArgumentException(s"Invalid header level: $level") } - case Paragraph(value) => + case Paragraph(value) => p(value.toHtml) - case DescriptionList(definitions) => + case DescriptionList(definitions) => dl( definitions.flatMap { case (span, helpDoc) => Seq( @@ -165,9 +189,11 @@ sealed trait Doc { self => ) }, ) - case Sequence(left, right) => + case Sequence(left, right) => left.toHtml ++ right.toHtml - case Listing(elements, listingType) => + case Listing(elements, _) if elements.isEmpty => + Html.Empty + case Listing(elements, listingType) => val elementsHtml = elements.map { doc => li(doc.toHtml) @@ -181,7 +207,10 @@ sealed trait Doc { self => Html.fromString(value) case Raw(_, docType) => throw new IllegalArgumentException(s"Unsupported raw doc type: $docType") + case Tagged(doc, _) => + doc.toHtml } + html ++ (if (tags.nonEmpty) Doc.unorderedListing(self.tags.map(Doc.p): _*).toHtml else Html.Empty) } def toHtmlSnippet: String = @@ -275,6 +304,9 @@ sealed trait Doc { self => case Doc.Raw(_, docType) => throw new IllegalArgumentException(s"Unsupported raw doc type: $docType") + + case Tagged(doc, _) => + renderHelpDoc(doc) } def renderSpan(span: Span): Unit = { @@ -321,6 +353,11 @@ sealed trait Doc { self => renderHelpDoc(this) + val tags = self.tags + if (tags.nonEmpty) { + writer.append("\n") + renderHelpDoc(Doc.unorderedListing(tags.map(Doc.p): _*)) + } writer.toString() + (if (color) Console.RESET else "") } @@ -350,6 +387,7 @@ object Doc { final case class DescriptionList(definitions: List[(Span, Doc)]) extends Doc final case class Sequence(left: Doc, right: Doc) extends Doc final case class Listing(elements: List[Doc], listingType: ListingType) extends Doc + final case class Tagged(doc: Doc, tgs: List[String]) extends Doc sealed trait ListingType object ListingType { case object Unordered extends ListingType diff --git a/zio-http/shared/src/main/scala/zio/http/endpoint/Endpoint.scala b/zio-http/shared/src/main/scala/zio/http/endpoint/Endpoint.scala index 8905a974c6..fe211871bb 100644 --- a/zio-http/shared/src/main/scala/zio/http/endpoint/Endpoint.scala +++ b/zio-http/shared/src/main/scala/zio/http/endpoint/Endpoint.scala @@ -347,7 +347,7 @@ final case class Endpoint[PathInput, Input, Err, Output, Middleware <: EndpointM output, error, codecError, - doc, + self.doc, mw, ) @@ -381,7 +381,7 @@ final case class Endpoint[PathInput, Input, Err, Output, Middleware <: EndpointM output, error, codecError, - doc, + self.doc, mw, ) @@ -611,7 +611,7 @@ final case class Endpoint[PathInput, Input, Err, Output, Middleware <: EndpointM output = (contentCodec ++ StatusCodec.status(Status.Ok) ?? doc) | self.output, error, codecError, - doc, + self.doc, mw, ) } @@ -635,7 +635,7 @@ final case class Endpoint[PathInput, Input, Err, Output, Middleware <: EndpointM output = (contentCodec ++ StatusCodec.status(status) ?? doc) | self.output, error, codecError, - doc, + self.doc, mw, ) } @@ -671,7 +671,7 @@ final case class Endpoint[PathInput, Input, Err, Output, Middleware <: EndpointM output = (contentCodec ++ StatusCodec.status(status)) | self.output, error, codecError, - doc, + self.doc, mw, ) } @@ -689,7 +689,7 @@ final case class Endpoint[PathInput, Input, Err, Output, Middleware <: EndpointM output = ((contentCodec ++ StatusCodec.status(status)) ?? doc) | self.output, error, codecError, - doc, + self.doc, mw, ) } @@ -702,6 +702,18 @@ final case class Endpoint[PathInput, Input, Err, Output, Middleware <: EndpointM ): Endpoint[PathInput, combiner.Out, Err, Output, Middleware] = copy(input = self.input ++ codec) + /** + * Adds tags to the endpoint. The are used for documentation generation. For + * example to group endpoints for OpenAPI. + */ + def tag(tag: String, tags: String*): Endpoint[PathInput, Input, Err, Output, Middleware] = + copy(doc = doc.tag(tag +: tags)) + + /** + * A list of tags for this endpoint. + */ + def tags: List[String] = doc.tags + /** * Transforms the input of this endpoint using the specified functions. This * is useful to build from different http inputs a domain specific input. 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 28e57d2783..a73a2d3d57 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 @@ -559,7 +559,7 @@ object OpenAPIGen { def operation(endpoint: Endpoint[_, _, _, _, _]): OpenAPI.Operation = { val maybeDoc = Some(endpoint.doc + pathDoc).filter(!_.isEmpty) OpenAPI.Operation( - tags = Nil, + tags = endpoint.tags, summary = None, description = maybeDoc, externalDocs = None,