diff --git a/rest/.jvm/src/test/resources/RestTestApi.json b/rest/.jvm/src/test/resources/RestTestApi.json index 43a09dd79..e0c98d5b0 100644 --- a/rest/.jvm/src/test/resources/RestTestApi.json +++ b/rest/.jvm/src/test/resources/RestTestApi.json @@ -177,6 +177,9 @@ }, "/failingGet": { "get": { + "tags": [ + "TrivialDescribedGroup" + ], "operationId": "failingGet", "responses": { "204": { @@ -495,6 +498,9 @@ "/prefix/{p0}/subget/{p1}": { "summary": "summary for prefix paths", "get": { + "tags": [ + "Prefix" + ], "operationId": "prefix_subget", "parameters": [ { @@ -642,6 +648,9 @@ }, "/trivialGet": { "get": { + "tags": [ + "TrivialGroup" + ], "operationId": "trivialGet", "responses": { "204": { @@ -941,5 +950,18 @@ ] } } - } + }, + "tags": [ + { + "name": "Prefix", + "description": "example API subgroup" + }, + { + "name": "TrivialGroup" + }, + { + "name": "TrivialDescribedGroup", + "description": "something" + } + ] } \ No newline at end of file diff --git a/rest/src/main/scala/io/udash/rest/openapi/OpenApiMetadata.scala b/rest/src/main/scala/io/udash/rest/openapi/OpenApiMetadata.scala index 66c943cea..ef645e03b 100644 --- a/rest/src/main/scala/io/udash/rest/openapi/OpenApiMetadata.scala +++ b/rest/src/main/scala/io/udash/rest/openapi/OpenApiMetadata.scala @@ -52,6 +52,16 @@ final case class OpenApiMetadata[T]( ) { val httpMethods: List[OpenApiOperation[_]] = (gets: List[OpenApiOperation[_]]) ++ customBodyMethods ++ bodyMethods + // collect all tags + private lazy val openApiTags: List[Tag] = { + def createTags(method: OpenApiMethod[_]): List[Tag] = + method.groupAnnot.fold[List[Tag]](Nil) { group => + List(method.tagAdjusters.foldLeft(Tag(group.groupName))({ case (tag, adjuster) => adjuster.adjustTag(tag) })) + } + + prefixes.flatMap(prefix => createTags(prefix) ++ prefix.result.value.openApiTags) ++ httpMethods.flatMap(createTags) + } + def operations(resolver: SchemaResolver): Iterator[PathOperation] = prefixes.iterator.flatMap(_.operations(resolver)) ++ httpMethods.iterator.map(_.pathOperation(resolver)) @@ -79,7 +89,7 @@ final case class OpenApiMetadata[T]( put = ops.getOpt(HttpMethod.PUT).toOptArg, post = ops.getOpt(HttpMethod.POST).toOptArg, patch = ops.getOpt(HttpMethod.PATCH).toOptArg, - delete = ops.getOpt(HttpMethod.DELETE).toOptArg + delete = ops.getOpt(HttpMethod.DELETE).toOptArg, ) (path, RefOr(pathAdjustersMap(path).foldRight(pathItem)(_ adjustPathItem _))) }.intoMap[ITreeMap]) @@ -94,14 +104,15 @@ final case class OpenApiMetadata[T]( externalDocs: OptArg[ExternalDocumentation] = OptArg.Empty ): OpenApi = { val registry = new SchemaRegistry(initial = components.schemas) - OpenApi(OpenApi.Version, + OpenApi( + OpenApi.Version, info, paths(registry), components = components.copy(schemas = registry.registeredSchemas), servers = servers, security = security, - tags = tags, - externalDocs = externalDocs + tags = openApiTags ++ tags, + externalDocs = externalDocs, ) } } @@ -119,7 +130,7 @@ final case class PathOperation( path: String, method: HttpMethod, operation: Operation, - pathAdjusters: List[PathItemAdjuster] + pathAdjusters: List[PathItemAdjuster], ) sealed trait OpenApiMethod[T] extends TypedMetadata[T] { @@ -128,6 +139,8 @@ sealed trait OpenApiMethod[T] extends TypedMetadata[T] { @multi @rpcParamMetadata @tagged[NonBodyTag] @allowOptional def parameters: List[OpenApiParameter[_]] @multi @reifyAnnot def operationAdjusters: List[OperationAdjuster] @multi @reifyAnnot def pathAdjusters: List[PathItemAdjuster] + @optional @reifyAnnot def groupAnnot: Opt[group] + @multi @reifyAnnot def tagAdjusters: List[TagAdjuster] val pathPattern: String = { val pathParts = methodTag.path :: parameters.flatMap { @@ -145,8 +158,10 @@ final case class OpenApiPrefix[T]( parameters: List[OpenApiParameter[_]], operationAdjusters: List[OperationAdjuster], pathAdjusters: List[PathItemAdjuster], + groupAnnot: Opt[group], + tagAdjusters: List[TagAdjuster], @optional @reifyAnnot operationIdPrefix: Opt[operationIdPrefix], - @infer @checked result: OpenApiMetadata.Lazy[T] + @infer @checked result: OpenApiMetadata.Lazy[T], ) extends OpenApiMethod[T] { def operations(resolver: SchemaResolver): Iterator[PathOperation] = { @@ -188,8 +203,10 @@ final case class OpenApiGetOperation[T]( methodTag: HttpMethodTag, operationAdjusters: List[OperationAdjuster], pathAdjusters: List[PathItemAdjuster], + groupAnnot: Opt[group], + tagAdjusters: List[TagAdjuster], parameters: List[OpenApiParameter[_]], - resultType: RestResultType[T] + resultType: RestResultType[T], ) extends OpenApiOperation[T] { def requestBody(resolver: SchemaResolver): Opt[RefOr[RequestBody]] = Opt.Empty } @@ -199,6 +216,8 @@ final case class OpenApiCustomBodyOperation[T]( methodTag: HttpMethodTag, operationAdjusters: List[OperationAdjuster], pathAdjusters: List[PathItemAdjuster], + groupAnnot: Opt[group], + tagAdjusters: List[TagAdjuster], parameters: List[OpenApiParameter[_]], @encoded @rpcParamMetadata @tagged[Body] @unmatched(RawRest.MissingBodyParam) singleBody: OpenApiBody[_], resultType: RestResultType[T] @@ -212,10 +231,12 @@ final case class OpenApiBodyOperation[T]( methodTag: HttpMethodTag, operationAdjusters: List[OperationAdjuster], pathAdjusters: List[PathItemAdjuster], + groupAnnot: Opt[group], + tagAdjusters: List[TagAdjuster], parameters: List[OpenApiParameter[_]], @multi @rpcParamMetadata @tagged[Body] @allowOptional bodyFields: List[OpenApiBodyField[_]], @reifyAnnot bodyTypeTag: BodyTypeTag, - resultType: RestResultType[T] + resultType: RestResultType[T], ) extends OpenApiOperation[T] { def requestBody(resolver: SchemaResolver): Opt[RefOr[RequestBody]] = @@ -238,7 +259,7 @@ final case class OpenApiParamInfo[T]( @optional @composite whenAbsentInfo: Opt[WhenAbsentInfo[T]], @isAnnotated[optionalParam] optional: Boolean, @reifyFlags flags: ParamFlags, - @infer restSchema: RestSchema[T] + @infer restSchema: RestSchema[T], ) extends TypedMetadata[T] { val whenAbsentValue: Opt[JsonValue] = if (optional) Opt.Empty @@ -259,7 +280,7 @@ final case class OpenApiParamInfo[T]( final case class OpenApiParameter[T]( @reifyAnnot paramTag: NonBodyTag, @composite info: OpenApiParamInfo[T], - @multi @reifyAnnot adjusters: List[ParameterAdjuster] + @multi @reifyAnnot adjusters: List[ParameterAdjuster], ) extends TypedMetadata[T] { def parameter(resolver: SchemaResolver): RefOr[Parameter] = { @@ -284,7 +305,7 @@ final case class OpenApiParameter[T]( final case class OpenApiBodyField[T]( @composite info: OpenApiParamInfo[T], - @multi @reifyAnnot schemaAdjusters: List[SchemaAdjuster] + @multi @reifyAnnot schemaAdjusters: List[SchemaAdjuster], ) extends TypedMetadata[T] { def schema(resolver: SchemaResolver): RefOr[Schema] = SchemaAdjuster.adjustRef(schemaAdjusters, info.schema(resolver, withDefaultValue = true)) @@ -292,7 +313,7 @@ final case class OpenApiBodyField[T]( final case class OpenApiBody[T]( @infer restRequestBody: RestRequestBody[T], - @multi @reifyAnnot schemaAdjusters: List[SchemaAdjuster] + @multi @reifyAnnot schemaAdjusters: List[SchemaAdjuster], ) extends TypedMetadata[T] { def requestBody(resolver: SchemaResolver): Opt[RefOr[RequestBody]] = { def transformSchema(schema: RestSchema[_]): RestSchema[_] = diff --git a/rest/src/main/scala/io/udash/rest/openapi/adjusters/Adjuster.scala b/rest/src/main/scala/io/udash/rest/openapi/adjusters/Adjuster.scala index e875ea98d..d85cf9957 100644 --- a/rest/src/main/scala/io/udash/rest/openapi/adjusters/Adjuster.scala +++ b/rest/src/main/scala/io/udash/rest/openapi/adjusters/Adjuster.scala @@ -3,7 +3,7 @@ package rest.openapi package adjusters import com.avsystem.commons._ -import com.avsystem.commons.annotation.NotInheritedFromSealedTypes +import com.avsystem.commons.annotation.{AnnotationAggregate, NotInheritedFromSealedTypes, defaultsToName} import com.avsystem.commons.meta.infer import com.avsystem.commons.rpc.AsRaw import io.udash.rest.raw._ @@ -46,8 +46,7 @@ object SchemaAdjuster { /** * Base trait for annotation which may adjust [[io.udash.rest.openapi.Parameter Parameter]] generated for path, - * query or header parameters - * of REST RPC methods. + * query or header parameters of REST RPC methods. */ trait ParameterAdjuster extends Adjuster { def adjustParameter(parameter: Parameter): Parameter @@ -122,6 +121,15 @@ trait SuccessfulResponseAdjuster extends OperationAdjuster { def adjustResponse(response: Response): Response } +/** + * Base trait for annotation which may adjust [[io.udash.rest.openapi.Tag Tag]] generated for REST HTTP methods. + * Tag adjusters may also be specified on prefix methods - they will be added to all operations + * generated for the result of this prefix method. + */ +trait TagAdjuster extends StaticAnnotation { + def adjustTag(tag: Tag): Tag +} + /** Convenience implementation of [[io.udash.rest.openapi.adjusters.SchemaAdjuster SchemaAdjuster]] */ class adjustSchema(f: Schema => Schema) extends SchemaAdjuster { def adjustSchema(value: Schema): Schema = f(value) @@ -138,6 +146,10 @@ class adjustOperation(f: Operation => Operation) extends OperationAdjuster { class adjustPathItem(f: PathItem => PathItem) extends PathItemAdjuster { def adjustPathItem(value: PathItem): PathItem = f(value) } +/** Convenience implementation of [[io.udash.rest.openapi.adjusters.TagAdjuster TagAdjuster]] */ +class adjustTag(f: Tag => Tag) extends TagAdjuster { + override def adjustTag(tag: Tag): Tag = f(tag) +} /** * Sets the `title` of a [[io.udash.rest.openapi.Schema Schema]]. It can be applied on standard @@ -253,6 +265,33 @@ class tags(tags: String*) extends OperationAdjuster { operation.copy(tags = operation.tags ++ tags) } +/** + * Annotation which may be applied on HTTP REST method to add a single Open API [[io.udash.rest.openapi.Tag Tag]] for + * that method. Group can be also defined on prefix methods - it will be applied to all operations generated for the + * result of this prefix method. + * + * @param name OpenAPI tag name, defaults to method name + */ +class group(@defaultsToName name: String = null) extends tags(name.capitalize) { + def groupName: String = name.capitalize +} + +/** + * Annotation which may be applied on HTTP REST method to specify description for + * [[io.udash.rest.openapi.Tag Tag]] defined in that method. + */ +class tagDescription(desc: String) extends TagAdjuster { + override def adjustTag(tag: Tag): Tag = tag.copy(description = desc) +} + +/** + * Shortcut aggregating both [[group]] and [[tagDescription]]. Always uses default `group` name. + */ +class describedGroup(desc: String) extends AnnotationAggregate { + @group @tagDescription(desc) + final def aggregated: List[StaticAnnotation] = reifyAggregated +} + /** * Prefix methods may be annotated with this annotation to specify prefix that will be prepended to * `operationId` of all [[io.udash.rest.openapi.Operation Operation]] objects generated for result of that prefix method. diff --git a/rest/src/test/scala/io/udash/rest/RestTestApi.scala b/rest/src/test/scala/io/udash/rest/RestTestApi.scala index 461ef266c..8deaf1b61 100644 --- a/rest/src/test/scala/io/udash/rest/RestTestApi.scala +++ b/rest/src/test/scala/io/udash/rest/RestTestApi.scala @@ -75,8 +75,8 @@ case class ErrorWrapper[T](error: T) object ErrorWrapper extends HasPolyGenCodec[ErrorWrapper] trait RestTestApi { - @GET def trivialGet: Future[Unit] - @GET def failingGet: Future[Unit] + @GET @group("TrivialGroup") def trivialGet: Future[Unit] + @GET @group("TrivialDescribedGroup") @tagDescription("something") def failingGet: Future[Unit] @GET def jsonFailingGet: Future[Unit] @GET def moreFailingGet: Future[Unit] @GET def neverGet: Future[Unit] @@ -124,6 +124,7 @@ trait RestTestApi { ): Future[String] @pathSummary("summary for prefix paths") + @describedGroup("example API subgroup") def prefix( p0: String, @Header("X-H0") h0: String,