diff --git a/airframe-codec/src/main/scala/wvlet/airframe/codec/MessageCodecFinder.scala b/airframe-codec/src/main/scala/wvlet/airframe/codec/MessageCodecFinder.scala index 386d02d7f4..4debc8b7d3 100644 --- a/airframe-codec/src/main/scala/wvlet/airframe/codec/MessageCodecFinder.scala +++ b/airframe-codec/src/main/scala/wvlet/airframe/codec/MessageCodecFinder.scala @@ -13,7 +13,8 @@ */ package wvlet.airframe.codec import wvlet.airframe.codec.ScalaStandardCodec.{EitherCodec, OptionCodec, TupleCodec} -import wvlet.airframe.surface.{Alias, EnumSurface, GenericSurface, Surface} +import wvlet.airframe.surface.{Alias, EnumSurface, GenericSurface, Surface, Union, Union2, Union3} +import wvlet.log.LogSupport /** */ @@ -26,7 +27,7 @@ trait MessageCodecFinder { def orElse(other: MessageCodecFinder): MessageCodecFinder = MessageCodecFinder.OrElse(this, other) } -object MessageCodecFinder { +object MessageCodecFinder extends LogSupport { object empty extends MessageCodecFinder { override def findCodec( factory: MessageCodecFactory, @@ -76,6 +77,10 @@ object MessageCodecFinder { OptionCodec(factory.ofSurface(elementSurface, seenSet)) case et: Surface if classOf[Either[_, _]].isAssignableFrom(et.rawType) => EitherCodec(factory.ofSurface(et.typeArgs(0)), factory.ofSurface(et.typeArgs(1))) + // Union Type + case g: Surface if classOf[Union2[_, _]] == g.rawType || classOf[Union3[_, _, _]] == g.rawType => + // Resolving classes extending Union2 or Union3 here to avoid infinite loop + UnionCodec(g.typeArgs.map(x => factory.ofSurface(x, seenSet))) // Tuple case g: GenericSurface if classOf[Product].isAssignableFrom(g.rawType) && g.rawType.getName.startsWith("scala.Tuple") => diff --git a/airframe-codec/src/main/scala/wvlet/airframe/codec/UnionCodec.scala b/airframe-codec/src/main/scala/wvlet/airframe/codec/UnionCodec.scala new file mode 100644 index 0000000000..6160ab19c1 --- /dev/null +++ b/airframe-codec/src/main/scala/wvlet/airframe/codec/UnionCodec.scala @@ -0,0 +1,53 @@ +/* + * 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 wvlet.airframe.codec +import wvlet.airframe.codec.PrimitiveCodec.StringCodec +import wvlet.airframe.msgpack.spi.{Packer, Unpacker} +import wvlet.airframe.surface.{Surface, Union} + +/** + * Codec for union classes (e.g., A or B) + * This codec is necessary for defining OpenAPI's model classes + */ +case class UnionCodec(codecs: Seq[MessageCodec[_]]) extends MessageCodec[Union] { + override def pack(p: Packer, v: Union): Unit = { + val cl = v.getElementClass + wvlet.airframe.codec.Compat.codecOfClass(cl) match { + case Some(codec) => + codec.asInstanceOf[MessageCodec[Any]].pack(p, v) + case None => + // Pack as a string + StringCodec.pack(p, v.toString) + } + } + override def unpack(u: Unpacker, v: MessageContext): Unit = { + // Read the value first + val msgPack = u.unpackValue.toMsgpack + // Try each codec + val found = codecs.find { x => + x.unpackMsgPack(msgPack).map { a: Any => + v.setObject(a) + }.isDefined + }.isDefined + if (!found) { + v.setError( + new MessageCodecException( + INVALID_DATA, + this, + s"No corresponding type is found for data: ${JSONCodec.fromMsgPack(msgPack)}" + ) + ) + } + } +} diff --git a/airframe-http-finagle/src/main/scala/wvlet/airframe/http/finagle/FinagleResponseHandler.scala b/airframe-http-finagle/src/main/scala/wvlet/airframe/http/finagle/FinagleResponseHandler.scala index cc13233791..8bcb0b9061 100644 --- a/airframe-http-finagle/src/main/scala/wvlet/airframe/http/finagle/FinagleResponseHandler.scala +++ b/airframe-http-finagle/src/main/scala/wvlet/airframe/http/finagle/FinagleResponseHandler.scala @@ -20,7 +20,7 @@ import com.twitter.finagle.http._ import com.twitter.io.Buf.ByteArray import com.twitter.io.{Buf, Reader} import wvlet.airframe.codec.{JSONCodec, MessageCodec, MessageCodecFactory} -import wvlet.airframe.http.router.ResponseHandler +import wvlet.airframe.http.router.{ResponseHandler, Route} import wvlet.airframe.http.{HttpMessage, HttpStatus} import wvlet.airframe.surface.{Primitive, Surface} import wvlet.log.LogSupport @@ -51,10 +51,13 @@ class FinagleResponseHandler(customCodec: PartialFunction[Surface, MessageCodec[ ) } - private def newResponse(request: Request, responseSurface: Surface): Response = { + private def newResponse(route: Route, request: Request, responseSurface: Surface): Response = { val r = Response(request) if (responseSurface == Primitive.Unit) { request.method match { + case Method.Post if route.isRPC => + // For RPC, return 200 even for POST + r.statusCode = HttpStatus.Ok_200.code case Method.Post | Method.Put => r.statusCode = HttpStatus.Created_201.code case Method.Delete => @@ -80,11 +83,11 @@ class FinagleResponseHandler(customCodec: PartialFunction[Surface, MessageCodec[ } // TODO: Extract this logic into airframe-http - def toHttpResponse[A](request: Request, responseSurface: Surface, a: A): Response = { + def toHttpResponse[A](route: Route, request: Request, responseSurface: Surface, a: A): Response = { a match { case null => // Empty response - val r = newResponse(request, responseSurface) + val r = newResponse(route, request, responseSurface) r case r: Response => // Return the response as is @@ -113,11 +116,11 @@ class FinagleResponseHandler(customCodec: PartialFunction[Surface, MessageCodec[ case r: HttpMessage.Response => convertToFinagleResponse(r) case b: Array[Byte] => - val r = newResponse(request, responseSurface) + val r = newResponse(route, request, responseSurface) r.content = Buf.ByteArray.Owned(b) r case s: String => - val r = newResponse(request, responseSurface) + val r = newResponse(route, request, responseSurface) r.contentString = s r case _ => @@ -134,7 +137,7 @@ class FinagleResponseHandler(customCodec: PartialFunction[Surface, MessageCodec[ // Return application/x-msgpack content type if (isMsgPackRequest(request)) { - val res = newResponse(request, responseSurface) + val res = newResponse(route, request, responseSurface) res.contentType = xMsgPack res.content = ByteArray.Owned(msgpack) res @@ -142,7 +145,7 @@ class FinagleResponseHandler(customCodec: PartialFunction[Surface, MessageCodec[ val json = JSONCodec.unpackMsgPack(msgpack) json match { case Some(j) => - val res = newResponse(request, responseSurface) + val res = newResponse(route, request, responseSurface) res.setContentTypeJson() res.setContentString(json.get) res diff --git a/airframe-http/.jvm/src/main/scala/wvlet/airframe/http/Router.scala b/airframe-http/.jvm/src/main/scala/wvlet/airframe/http/Router.scala index a7a7b9a9a1..18a23233cd 100644 --- a/airframe-http/.jvm/src/main/scala/wvlet/airframe/http/Router.scala +++ b/airframe-http/.jvm/src/main/scala/wvlet/airframe/http/Router.scala @@ -56,16 +56,19 @@ case class Router( override def toString: String = printNode(0) + private def routerName: String = { + surface + .orElse(filterSurface) + .orElse(filterInstance.map(_.getClass.getSimpleName)) + .getOrElse(f"${hashCode()}%x") + .toString + } + private def printNode(indentLevel: Int): String = { val s = Seq.newBuilder[String] val ws = " " * (indentLevel * 2) - val name = - surface - .orElse(filterSurface) - .orElse(filterInstance.map(_.getClass.getSimpleName)) - .getOrElse(f"${hashCode()}%x") - s += s"${ws}- Router[${name}]" + s += s"${ws}- Router[${routerName}]" for (r <- localRoutes) { s += s"${ws} + ${r}" diff --git a/airframe-http/.jvm/src/main/scala/wvlet/airframe/http/codegen/HttpClientGenerator.scala b/airframe-http/.jvm/src/main/scala/wvlet/airframe/http/codegen/HttpCodeGenerator.scala similarity index 66% rename from airframe-http/.jvm/src/main/scala/wvlet/airframe/http/codegen/HttpClientGenerator.scala rename to airframe-http/.jvm/src/main/scala/wvlet/airframe/http/codegen/HttpCodeGenerator.scala index 14a82112fc..3308020f2a 100644 --- a/airframe-http/.jvm/src/main/scala/wvlet/airframe/http/codegen/HttpClientGenerator.scala +++ b/airframe-http/.jvm/src/main/scala/wvlet/airframe/http/codegen/HttpCodeGenerator.scala @@ -19,6 +19,7 @@ import wvlet.airframe.codec.MessageCodec import wvlet.airframe.control.Control import wvlet.airframe.http.Router import wvlet.airframe.http.codegen.client.{AsyncClient, HttpClientType} +import wvlet.airframe.http.openapi.OpenAPI import wvlet.airframe.launcher.Launcher import wvlet.log.{LogLevel, LogSupport, Logger} @@ -63,12 +64,11 @@ object HttpClientGeneratorConfig { /** * Generate HTTP client code for Scala, Scala.js targets using a given IR */ -object HttpClientGenerator extends LogSupport { +object HttpCodeGenerator extends LogSupport { def generate( router: Router, config: HttpClientGeneratorConfig ): String = { - val ir = HttpClientIR.buildIR(router, config) val code = config.clientType.generate(ir) debug(code) @@ -81,8 +81,21 @@ object HttpClientGenerator extends LogSupport { code } + def generateOpenAPI(router: Router, formatType: String, title: String, version: String): String = { + val openapi = OpenAPI.ofRouter(router).withInfo(OpenAPI.Info(title = title, version = version)) + val schema = formatType match { + case "yaml" => + openapi.toYAML + case "json" => + openapi.toJSON + case other => + throw new IllegalArgumentException(s"Unknown file format type: ${other}. Required yaml or json") + } + schema + } + def main(args: Array[String]): Unit = { - Launcher.of[HttpClientGenerator].execute(args) + Launcher.of[HttpCodeGenerator].execute(args) } case class Artifacts(file: Seq[File]) @@ -90,7 +103,7 @@ object HttpClientGenerator extends LogSupport { import wvlet.airframe.launcher._ -class HttpClientGenerator( +class HttpCodeGenerator( @option(prefix = "-h,--help", description = "show help message", isHelp = true) isHelp: Boolean = false, @option(prefix = "-l,--loglevel", description = "log level") @@ -98,13 +111,26 @@ class HttpClientGenerator( ) extends LogSupport { Logger.init - logLevel.foreach { x => Logger("wvlet.airframe.http").setLogLevel(x) } + logLevel.foreach { x => + Logger("wvlet.airframe.http").setLogLevel(x) + } @command(isDefault = true) def default = { info(s"Type --help for the available options") } + private def newClassLoader(classpath: String): URLClassLoader = { + val cp = classpath.split(":").map(x => new File(x).toURI.toURL).toArray + new URLClassLoader(cp) + } + + private def buildRouter(apiPackageNames: Seq[String], classLoader: URLClassLoader): Router = { + info(s"Target API packages: ${apiPackageNames.mkString(", ")}") + val router = RouteScanner.buildRouter(apiPackageNames, classLoader) + router + } + @command(description = "Generate HTTP client codes") def generate( @option(prefix = "-cp", description = "semi-colon separated application classpaths") @@ -117,8 +143,7 @@ class HttpClientGenerator( targets: Seq[String] = Seq.empty ): Unit = { try { - val cp = classpath.split(":").map(x => new File(x).toURI.toURL).toArray - val cl = new URLClassLoader(cp) + val cl = newClassLoader(classpath) val artifacts = for (x <- targets) yield { val config = HttpClientGeneratorConfig(x) debug(config) @@ -128,15 +153,14 @@ class HttpClientGenerator( val path = s"${config.targetPackageName.replaceAll("\\.", "/")}/${config.fileName}" val outputFile = new File(outDir, path) - val router = RouteScanner.buildRouter(Seq(config.apiPackageName), cl) + val router = buildRouter(Seq(config.apiPackageName), cl) val routerStr = router.toString val routerHash = routerStr.hashCode val routerHashFile = new File(targetDir, f"router-${config.clientType.name}-${routerHash}%07x.update") if (!outputFile.exists() || !routerHashFile.exists()) { - outputFile.getParentFile.mkdirs() info(f"Router for package ${config.apiPackageName}:\n${routerStr}") info(s"Generating a ${config.clientType.name} client code: ${path}") - val code = HttpClientGenerator.generate(router, config) + val code = HttpCodeGenerator.generate(router, config) touch(routerHashFile) writeFile(outputFile, code) } else { @@ -152,6 +176,30 @@ class HttpClientGenerator( } } + @command(description = "Generate OpenAPI spec") + def openapi( + @option(prefix = "-cp", description = "semi-colon separated application classpaths") + classpath: String = "", + @option(prefix = "-o", description = "output file") + outFile: File, + @option(prefix = "-f", description = "format type: yaml (default) or json") + formatType: String = "YAML", + @option(prefix = "--title", description = "openapi.title") + title: String, + @option(prefix = "--version", description = "openapi.version") + version: String, + @argument(description = "Target Airframe HTTP/RPC package name") + packageNames: Seq[String] + ): Unit = { + debug(s"classpath: ${classpath}") + val router = buildRouter(packageNames, newClassLoader(classpath)) + debug(router) + val schema = HttpCodeGenerator.generateOpenAPI(router, formatType, title, version) + debug(schema) + info(s"Writing OpenAPI spec ${formatType} to ${outFile.getPath}") + writeFile(outFile, schema) + } + private def touch(f: File): Unit = { if (!f.createNewFile()) { f.setLastModified(System.currentTimeMillis()) @@ -159,7 +207,10 @@ class HttpClientGenerator( } private def writeFile(outputFile: File, data: String): Unit = { - Control.withResource(new FileWriter(outputFile)) { out => out.write(data); out.flush() } + outputFile.getParentFile.mkdirs() + Control.withResource(new FileWriter(outputFile)) { out => + out.write(data); out.flush() + } } } diff --git a/airframe-http/.jvm/src/main/scala/wvlet/airframe/http/codegen/RouteAnalyzer.scala b/airframe-http/.jvm/src/main/scala/wvlet/airframe/http/codegen/RouteAnalyzer.scala index 365df5382f..5ded6d9a72 100644 --- a/airframe-http/.jvm/src/main/scala/wvlet/airframe/http/codegen/RouteAnalyzer.scala +++ b/airframe-http/.jvm/src/main/scala/wvlet/airframe/http/codegen/RouteAnalyzer.scala @@ -18,7 +18,7 @@ import wvlet.airframe.http.router.Route import wvlet.airframe.surface.{CName, MethodParameter} /** - * Analyze a given HTTP Ruote, and build URL path strings, user-input arguments, and http client call arguments. + * Analyze a given HTTP Route, and build URL path strings, user-input arguments, and http client call arguments. */ object RouteAnalyzer { @@ -27,7 +27,7 @@ object RouteAnalyzer { pathString: String, // User-input parameters for the client method userInputParameters: Seq[MethodParameter], - private val pathOnlyParameters: Set[MethodParameter] + pathOnlyParameters: Set[MethodParameter] ) { // http client call parameters, except parameters used for generating path strings val httpClientCallInputs: Seq[MethodParameter] = (userInputParameters.toSet -- pathOnlyParameters).toIndexedSeq diff --git a/airframe-http/.jvm/src/main/scala/wvlet/airframe/http/openapi/OpenAPI.scala b/airframe-http/.jvm/src/main/scala/wvlet/airframe/http/openapi/OpenAPI.scala new file mode 100644 index 0000000000..aab4745bbe --- /dev/null +++ b/airframe-http/.jvm/src/main/scala/wvlet/airframe/http/openapi/OpenAPI.scala @@ -0,0 +1,184 @@ +/* + * 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 wvlet.airframe.http.openapi + +import wvlet.airframe.codec.MessageCodecFactory +import wvlet.airframe.http.Router +import wvlet.airframe.http.openapi.OpenAPI._ +import wvlet.airframe.json.YAMLFormatter +import wvlet.airframe.surface.Union2 + +case class OpenAPI( + openapi: String = "3.0.3", + info: Info = Info(title = "API", version = "0.1"), + paths: Map[String, Map[String, PathItem]], + components: Option[Components] = None +) { + + /** + * Set the info of the router + * @param info + * @return + */ + def withInfo(info: Info): OpenAPI = { + this.copy(info = info) + } + + def toJSON: String = { + codec.toJson(this) + } + + def toYAML: String = { + YAMLFormatter.toYaml(toJSON) + } +} + +/** + * A subset of Open API objects necessary for describing Airframe RPC interfaces + */ +object OpenAPI { + private val codec = MessageCodecFactory.defaultFactoryForJSON.of[OpenAPI] + + def parseJson(json: String): OpenAPI = codec.fromJson(json) + // TODO: Create airframe-yaml https://github.com/wvlet/airframe/issues/1185 + // def parseYaml(yaml: String): OpenAPI = ??? + + /** + * Generate Open API model class from Airframe HTTP/RPC Router definition + * @param router + * @return OpenAPI model class + */ + def ofRouter(router: Router, config: OpenAPIGeneratorConfig = OpenAPIGeneratorConfig()): OpenAPI = { + OpenAPIGenerator.buildFromRouter(router, config) + } + + case class Info( + title: String, + version: String, + description: Option[String] = None, + termsOfService: Option[String] = None + ) + + case class License(name: String, url: Option[String] = None) + def APL2 = License("Apache 2.0", Some("https://www.apache.org/licenses/LICENSE-2.0.html")) + + case class PathItem( + summary: String, + description: String, + operationId: String, + parameters: Option[Seq[ParameterOrRef]] = None, + requestBody: Option[RequestBody] = None, + // Status Code -> ResponseRef or Response + responses: Map[String, Union2[Response, ResponseRef]], + tags: Option[Seq[String]] = None + ) + + type ParameterOrRef = Union2[Parameter, ParameterRef] + + case class Parameter( + name: String, + in: In, + description: Option[String] = None, + required: Boolean = false, + schema: Option[SchemaOrRef] = None, + deprecated: Option[Boolean] = None, + allowEmptyValue: Option[Boolean] = None + ) extends ParameterOrRef { + override def getElementClass = classOf[Parameter] + } + + case class ParameterRef( + `$ref`: String + ) extends ParameterOrRef { + override def getElementClass = classOf[ParameterRef] + } + + sealed trait In + + object In { + case object query extends In + case object header extends In + case object path extends In + case object cookie extends In + + private def all = Seq(query, header, path, cookie) + + def unapply(s: String): Option[In] = { + all.find(x => x.toString == s) + } + } + + case class RequestBody( + description: Option[String] = None, + // content-type -> MediaType + content: Map[String, MediaType], + required: Boolean = false + ) + + type SchemaOrRef = Union2[Schema, SchemaRef] + + case class MediaType( + // Scheme or SchemaRef, + schema: SchemaOrRef, + encoding: Option[Map[String, Encoding]] = None + ) + + case class SchemaRef( + `$ref`: String + ) extends SchemaOrRef { + override def getElementClass = classOf[SchemaRef] + } + + case class Schema( + `type`: String, + format: Option[String] = None, + description: Option[String] = None, + required: Option[Seq[String]] = None, + // property name -> property object + properties: Option[Map[String, SchemaOrRef]] = None, + // For Map-type values + additionalProperties: Option[SchemaOrRef] = None, + items: Option[SchemaOrRef] = None, + nullable: Option[Boolean] = None, + enum: Option[Seq[String]] = None + ) extends SchemaOrRef { + override def getElementClass = classOf[Schema] + } + + case class Encoding() + + case class ResponseRef( + `$ref`: String + ) extends Union2[Response, ResponseRef] { + def getElementClass = classOf[ResponseRef] + } + + case class Response( + description: String, + headers: Option[Map[String, Header]] = None, + // Status code string -> MediaType + content: Map[String, MediaType] = Map.empty + ) extends Union2[Response, ResponseRef] { + override def getElementClass = classOf[Response] + } + + case class Header() + + case class Components( + schemas: Option[Map[String, SchemaOrRef]] = None, + responses: Option[Map[String, Response]] = None, + parameters: Option[Map[String, ParameterOrRef]] = None + ) + +} diff --git a/airframe-http/.jvm/src/main/scala/wvlet/airframe/http/openapi/OpenAPIGenerator.scala b/airframe-http/.jvm/src/main/scala/wvlet/airframe/http/openapi/OpenAPIGenerator.scala new file mode 100644 index 0000000000..b637c64bf0 --- /dev/null +++ b/airframe-http/.jvm/src/main/scala/wvlet/airframe/http/openapi/OpenAPIGenerator.scala @@ -0,0 +1,315 @@ +/* + * 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 wvlet.airframe.http.openapi +import java.util.Locale + +import wvlet.airframe.http.{HttpMethod, HttpStatus, Router} +import wvlet.airframe.http.codegen.RouteAnalyzer +import wvlet.airframe.http.openapi.OpenAPI.Response +import wvlet.airframe.surface.{ArraySurface, GenericSurface, MethodParameter, OptionSurface, Primitive, Surface, Union2} +import wvlet.log.LogSupport + +import scala.collection.immutable.ListMap + +case class OpenAPIGeneratorConfig( + // status code -> Response + commonErrorResponses: Map[String, OpenAPI.Response] = ListMap( + "400" -> Response( + description = HttpStatus.BadRequest_400.reason + ), + "500" -> Response( + description = HttpStatus.InternalServerError_500.reason + ), + "503" -> Response( + description = HttpStatus.ServiceUnavailable_503.reason + ) + ) +) + +/** + * OpenAPI schema generator + */ +private[openapi] object OpenAPIGenerator extends LogSupport { + import OpenAPI._ + + /** + * Sanitize the given class name as Open API doesn't support names containing $ + */ + private def sanitizedSurfaceName(s: Surface): String = { + s match { + case o: OptionSurface => + sanitizedSurfaceName(o.elementSurface) + case _ => + s.fullName.replaceAll("\\$", ".") + } + } + + /** + * Check whether the type is a primitive (no need to use component reference) or not + */ + private def isPrimitiveTypeFamily(s: Surface): Boolean = { + s match { + case s if s.isPrimitive => true + case o: OptionSurface => o.elementSurface.isPrimitive + case other => false + } + } + + private[openapi] def buildFromRouter(router: Router, config: OpenAPIGeneratorConfig): OpenAPI = { + val referencedSchemas = Map.newBuilder[String, SchemaOrRef] + + val paths = for (route <- router.routes) yield { + val routeAnalysis = RouteAnalyzer.analyzeRoute(route) + trace(routeAnalysis) + + // Replace path parameters into + val path = "/" + route.pathComponents + .map { p => + p match { + case x if x.startsWith(":") => + s"{${x.substring(1, x.length)}}" + case x if x.startsWith("*") => + s"{${x.substring(1, x.length)}}" + case _ => + p + } + }.mkString("/") + + // User HTTP request body + val requestMediaType = MediaType( + schema = Schema( + `type` = "object", + required = requiredParams(routeAnalysis.userInputParameters), + properties = Some( + routeAnalysis.httpClientCallInputs.map { p => + p.name -> getOpenAPISchema(p.surface, useRef = true) + }.toMap + ) + ) + ) + val requestBodyContent: Map[String, MediaType] = { + if (route.method == HttpMethod.GET) { + // GET should have no request body + Map.empty + } else { + if (routeAnalysis.userInputParameters.isEmpty) { + Map.empty + } else { + Map( + "application/json" -> requestMediaType, + "application/x-msgpack" -> requestMediaType + ) + } + } + } + + /** + * Register a component for creating a reference link + */ + def registerComponent(s: Surface): Unit = { + s match { + case s if isPrimitiveTypeFamily(s) => + // Do not register schema + case _ => + referencedSchemas += sanitizedSurfaceName(s) -> getOpenAPISchema(s, useRef = false) + } + } + + // Http response type + routeAnalysis.httpClientCallInputs.foreach { p => + registerComponent(p.surface) + } + val returnTypeName = sanitizedSurfaceName(route.returnTypeSurface) + registerComponent(route.returnTypeSurface) + + def toParameter(p: MethodParameter, in: In): ParameterOrRef = { + if (p.surface.isPrimitive) { + Parameter( + name = p.name, + in = in, + required = true, + schema = if (isPrimitiveTypeFamily(p.surface)) { + Some(getOpenAPISchema(p.surface, useRef = false)) + } else { + registerComponent(p.surface) + Some(SchemaRef(s"#/components/schemas/${sanitizedSurfaceName(p.surface)}")) + }, + allowEmptyValue = if (p.getDefaultValue.nonEmpty) Some(true) else None + ) + } else { + ParameterRef(s"#/components/parameters/${sanitizedSurfaceName(p.surface)}") + } + } + + // URL path parameters (e.g., /:id/, /*path, etc.) + val pathParameters: Seq[ParameterOrRef] = routeAnalysis.pathOnlyParameters.toSeq.map { p => + toParameter(p, In.path) + } + // URL query string parameters + val queryParameters: Seq[ParameterOrRef] = if (route.method == HttpMethod.GET) { + routeAnalysis.httpClientCallInputs.map { p => + toParameter(p, In.query) + } + } else { + Seq.empty + } + val pathAndQueryParameters = pathParameters ++ queryParameters + + val httpMethod = route.method.toLowerCase(Locale.ENGLISH) + + val content: Map[String, MediaType] = if (route.returnTypeSurface == Primitive.Unit) { + Map.empty + } else { + val responseSchema = if (isPrimitiveTypeFamily(route.returnTypeSurface)) { + getOpenAPISchema(route.returnTypeSurface, useRef = false) + } else { + SchemaRef(s"#/components/schemas/${returnTypeName}") + } + + Map( + "application/json" -> MediaType( + schema = responseSchema + ), + "application/x-msgpack" -> MediaType( + schema = responseSchema + ) + ) + } + + val pathItem = PathItem( + summary = route.methodSurface.name, + // TODO Use @RPC(description = ???) or Scaladoc comment + description = route.methodSurface.name, + operationId = route.methodSurface.name, + parameters = if (pathAndQueryParameters.isEmpty) None else Some(pathAndQueryParameters), + requestBody = if (requestBodyContent.isEmpty) { + None + } else { + Some( + RequestBody( + content = requestBodyContent, + required = true + ) + ) + }, + responses = Map( + "200" -> + Response( + description = s"RPC response", + content = content + ) + ) ++ config.commonErrorResponses + .map(_._1).map { statusCode => + statusCode -> ResponseRef(s"#/components/responses/${statusCode}") + }.toMap, + tags = if (route.isRPC) Some(Seq("rpc")) else None + ) + path -> Map(httpMethod -> pathItem) + } + + val schemas = referencedSchemas.result() + + OpenAPI( + // Use ListMap for preserving the order + paths = ListMap.newBuilder.++=(paths).result(), + components = Some( + Components( + schemas = if (schemas.isEmpty) None else Some(schemas), + responses = if (config.commonErrorResponses.isEmpty) None else Some(config.commonErrorResponses) + ) + ) + ) + } + + def getOpenAPISchema(s: Surface, useRef: Boolean): SchemaOrRef = { + s match { + case Primitive.Unit => + Schema( + `type` = "string" + ) + case Primitive.Int => + Schema( + `type` = "integer", + format = Some("int32") + ) + case Primitive.Long => + Schema( + `type` = "integer", + format = Some("int64") + ) + case Primitive.Float => + Schema( + `type` = "number", + format = Some("float") + ) + case Primitive.Double => + Schema( + `type` = "number", + format = Some("double") + ) + case Primitive.Boolean => + Schema(`type` = "boolean") + case Primitive.String => + Schema(`type` = "string") + case a if a == Surface.of[Any] => + // We should use anyOf here, but it will complicate the handling of Map[String, Any] (additionalParameter: {}), so + // just use string type: + Schema(`type` = "string") + case o: OptionSurface => + getOpenAPISchema(o.elementSurface, useRef) + case g: Surface if classOf[Map[_, _]].isAssignableFrom(g.rawType) && g.typeArgs(0) == Primitive.String => + Schema( + `type` = "object", + additionalProperties = Some( + getOpenAPISchema(g.typeArgs(1), useRef) + ) + ) + case a: ArraySurface => + Schema( + `type` = "array", + items = Some(getOpenAPISchema(a.elementSurface, useRef)) + ) + case s: Surface if s.isSeq => + Schema( + `type` = "array", + items = Some( + getOpenAPISchema(s.typeArgs.head, useRef) + ) + ) + case s: Surface if useRef => + SchemaRef(`$ref` = s"#/components/schemas/${sanitizedSurfaceName(s)}") + case g: Surface if g.params.length > 0 => + // Use ListMap for preserving parameter orders + val b = ListMap.newBuilder[String, SchemaOrRef] + g.params.foreach { p => + b += p.name -> getOpenAPISchema(p.surface, useRef) + } + val properties = b.result() + + Schema( + `type` = "object", + required = requiredParams(g.params), + properties = if (properties.isEmpty) None else Some(properties) + ) + } + } + + private def requiredParams(params: Seq[wvlet.airframe.surface.Parameter]): Option[Seq[String]] = { + val required = params + .filter(p => p.isRequired || !p.surface.isOption) + .map(_.name) + if (required.isEmpty) None else Some(required) + } + +} diff --git a/airframe-http/.jvm/src/main/scala/wvlet/airframe/http/router/HttpEndpointExecutionContext.scala b/airframe-http/.jvm/src/main/scala/wvlet/airframe/http/router/HttpEndpointExecutionContext.scala index edf775927c..0c6d4136ed 100644 --- a/airframe-http/.jvm/src/main/scala/wvlet/airframe/http/router/HttpEndpointExecutionContext.scala +++ b/airframe-http/.jvm/src/main/scala/wvlet/airframe/http/router/HttpEndpointExecutionContext.scala @@ -63,7 +63,7 @@ class HttpEndpointExecutionContext[Req: HttpRequestAdapter, Resp, F[_]]( // If X is other type, convert X into an HttpResponse backend.mapF( result.asInstanceOf[F[_]], - { x: Any => responseHandler.toHttpResponse(request, futureValueSurface, x) } + { x: Any => responseHandler.toHttpResponse(route, request, futureValueSurface, x) } ) } case cl: Class[_] if backend.isScalaFutureType(cl) => @@ -81,12 +81,12 @@ class HttpEndpointExecutionContext[Req: HttpRequestAdapter, Resp, F[_]]( // If X is other type, convert X into an HttpResponse val scalaFuture = result .asInstanceOf[scala.concurrent.Future[_]] - .map { x => responseHandler.toHttpResponse(request, futureValueSurface, x) }(ex) + .map { x => responseHandler.toHttpResponse(route, request, futureValueSurface, x) }(ex) backend.toFuture(scalaFuture, ex) } case _ => // If the route returns non future value, convert it into Future response - backend.toFuture(responseHandler.toHttpResponse(request, route.returnTypeSurface, result)) + backend.toFuture(responseHandler.toHttpResponse(route, request, route.returnTypeSurface, result)) } } } diff --git a/airframe-http/.jvm/src/main/scala/wvlet/airframe/http/router/ResponseHandler.scala b/airframe-http/.jvm/src/main/scala/wvlet/airframe/http/router/ResponseHandler.scala index e36101e503..a05b220fbe 100644 --- a/airframe-http/.jvm/src/main/scala/wvlet/airframe/http/router/ResponseHandler.scala +++ b/airframe-http/.jvm/src/main/scala/wvlet/airframe/http/router/ResponseHandler.scala @@ -18,5 +18,5 @@ import wvlet.airframe.surface.Surface /** */ trait ResponseHandler[Req, Res] { - def toHttpResponse[A](request: Req, responseTypeSurface: Surface, a: A): Res + def toHttpResponse[A](route: Route, request: Req, responseTypeSurface: Surface, a: A): Res } diff --git a/airframe-http/.jvm/src/test/scala/example/openapi/OpenAPIExample.scala b/airframe-http/.jvm/src/test/scala/example/openapi/OpenAPIExample.scala new file mode 100644 index 0000000000..3318e5dae9 --- /dev/null +++ b/airframe-http/.jvm/src/test/scala/example/openapi/OpenAPIExample.scala @@ -0,0 +1,114 @@ +/* + * 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 example.openapi +import wvlet.airframe.http.{Endpoint, HttpMethod, RPC} + +/** + */ +@RPC +trait OpenAPIRPCExample { + import OpenAPIRPCExample._ + + def zeroAryRPC: Unit + def rpcWithPrimitive(p1: Int): Int + def rpcWithMultiplePrimitives(p1: Int, p2: String): Int + def rpcWithComplexParam(p1: RPCRequest): RPCResponse + def rpcWithMultipleParams(p1: Int, p2: RPCRequest): RPCResponse + def rpcWithOption(p1: Option[String]): Unit + def rpcWithPrimitiveAndOption(p1: String, p2: Option[String]): Unit + def rpcWithOptionOfComplexType(p1: Option[RPCRequest]): Unit +} + +object OpenAPIRPCExample { + case class RPCRequest( + x1: Int, + x2: Long, + x3: Boolean, + x4: Float, + x5: Double, + x6: Array[String], + x7: Seq[String], + x8: Map[String, Any], + x9: Option[Int] = None + ) + case class RPCResponse(y1: String, y2: Boolean) +} + +trait OpenAPIEndpointExample { + import OpenAPIEndpointExample._ + + @Endpoint(method = HttpMethod.GET, path = "/v1/get0") + def get0(): Unit + + @Endpoint(method = HttpMethod.GET, path = "/v1/get1/:id") + def get1(id: Int): Unit + + @Endpoint(method = HttpMethod.GET, path = "/v1/get2/:id/:name") + def get2(id: Int, name: String): Unit + + @Endpoint(method = HttpMethod.GET, path = "/v1/get3/:id") + def get3(id: Int, p1: String): Unit + + @Endpoint(method = HttpMethod.POST, path = "/v1/post1") + def post1(): Unit + + @Endpoint(method = HttpMethod.POST, path = "/v1/post2/:id") + def post2(id: Int): Unit + + @Endpoint(method = HttpMethod.POST, path = "/v1/post3/:id/:name") + def post3(id: Int, name: String): Unit + + @Endpoint(method = HttpMethod.POST, path = "/v1/post4/:id") + def post4(id: Int, p1: String): Unit + + @Endpoint(method = HttpMethod.POST, path = "/v1/post5") + def post5(p1: EndpointRequest): EndpointResponse + + @Endpoint(method = HttpMethod.POST, path = "/v1/post6/:id") + def post6(id: Int, p1: EndpointRequest): EndpointResponse + + @Endpoint(method = HttpMethod.PUT, path = "/v1/put1") + def put1(): Unit + + @Endpoint(method = HttpMethod.DELETE, path = "/v1/delete1") + def delete1(): Unit + + @Endpoint(method = HttpMethod.PATCH, path = "/v1/patch1") + def patch1(): Unit + + @Endpoint(method = HttpMethod.HEAD, path = "/v1/head1") + def head1(): Unit + + @Endpoint(method = HttpMethod.OPTIONS, path = "/v1/options1") + def options1(): Unit + + @Endpoint(method = HttpMethod.TRACE, path = "/v1/trace1") + def trace1(): Unit + +} + +object OpenAPIEndpointExample { + case class EndpointRequest( + x1: Int, + x2: Long, + x3: Boolean, + x4: Float, + x5: Double, + x6: Array[String], + x7: Seq[String], + x8: Map[String, Any], + x9: Option[Int] = None + ) + case class EndpointResponse(y1: String, y2: Boolean) +} diff --git a/airframe-http/.jvm/src/test/scala/wvlet/airframe/http/codegen/GenericServiceTest.scala b/airframe-http/.jvm/src/test/scala/wvlet/airframe/http/codegen/GenericServiceTest.scala index eae4ab80a8..a9225d09a2 100644 --- a/airframe-http/.jvm/src/test/scala/wvlet/airframe/http/codegen/GenericServiceTest.scala +++ b/airframe-http/.jvm/src/test/scala/wvlet/airframe/http/codegen/GenericServiceTest.scala @@ -27,7 +27,7 @@ class GenericServiceTest extends AirSpec { test("support F and Future return values in async clients") { debug(router) - val code = HttpClientGenerator.generate(router, HttpClientGeneratorConfig("example.generic:async")) + val code = HttpCodeGenerator.generate(router, HttpClientGeneratorConfig("example.generic:async")) code.contains(": F[String]") shouldBe true code.contains(": F[Int]") shouldBe true code.contains("import wvlet.airframe.http.HttpMessage.Response") @@ -36,7 +36,7 @@ class GenericServiceTest extends AirSpec { test("support F and Future return values in sync clients") { debug(router) - val code = HttpClientGenerator.generate(router, HttpClientGeneratorConfig("example.generic:sync")) + val code = HttpCodeGenerator.generate(router, HttpClientGeneratorConfig("example.generic:sync")) code.contains(": String = {") shouldBe true code.contains(": Int = {") shouldBe true code.contains("import wvlet.airframe.http.HttpMessage.Response") @@ -45,7 +45,7 @@ class GenericServiceTest extends AirSpec { test("support F and Future return values in Scala.js clients") { debug(router) - val code = HttpClientGenerator.generate(router, HttpClientGeneratorConfig("example.generic:scalajs")) + val code = HttpCodeGenerator.generate(router, HttpClientGeneratorConfig("example.generic:scalajs")) code.contains(": Future[String] = {") shouldBe true code.contains("Surface.of[String]") shouldBe true code.contains(": Future[Int] = {") shouldBe true @@ -58,6 +58,6 @@ class GenericServiceTest extends AirSpec { pending("Not sure using backend specific request/response in IDL is a good idea") val r = RouteScanner.buildRouter(Seq(classOf[GenericRequestService[Future, Request, Response]])) debug(r) - val code = HttpClientGenerator.generate(r, HttpClientGeneratorConfig("example.generic.GenericRequestService:async")) + val code = HttpCodeGenerator.generate(r, HttpClientGeneratorConfig("example.generic.GenericRequestService:async")) } } diff --git a/airframe-http/.jvm/src/test/scala/wvlet/airframe/http/codegen/HttpClientGeneratorTest.scala b/airframe-http/.jvm/src/test/scala/wvlet/airframe/http/codegen/HttpClientGeneratorTest.scala index fa56f04a6c..3aa3b6dbff 100644 --- a/airframe-http/.jvm/src/test/scala/wvlet/airframe/http/codegen/HttpClientGeneratorTest.scala +++ b/airframe-http/.jvm/src/test/scala/wvlet/airframe/http/codegen/HttpClientGeneratorTest.scala @@ -35,7 +35,7 @@ class HttpClientGeneratorTest extends AirSpec { } test("generate async client") { - val code = HttpClientGenerator.generate( + val code = HttpCodeGenerator.generate( router, HttpClientGeneratorConfig("example.api:async:example.api.client") ) @@ -53,7 +53,7 @@ class HttpClientGeneratorTest extends AirSpec { } test("generate sync client") { - val code = HttpClientGenerator.generate( + val code = HttpCodeGenerator.generate( router, HttpClientGeneratorConfig("example.api:sync") ) @@ -62,7 +62,7 @@ class HttpClientGeneratorTest extends AirSpec { } test("generate Scala.js client") { - val code = HttpClientGenerator.generate( + val code = HttpCodeGenerator.generate( router, HttpClientGeneratorConfig("example.api:scalajs:example.api.client.js") ) diff --git a/airframe-http/.jvm/src/test/scala/wvlet/airframe/http/openapi/OpenAPITest.scala b/airframe-http/.jvm/src/test/scala/wvlet/airframe/http/openapi/OpenAPITest.scala new file mode 100644 index 0000000000..e6eafc32fa --- /dev/null +++ b/airframe-http/.jvm/src/test/scala/wvlet/airframe/http/openapi/OpenAPITest.scala @@ -0,0 +1,489 @@ +/* + * 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 wvlet.airframe.http.openapi +import example.openapi.{OpenAPIEndpointExample, OpenAPIRPCExample} +import io.swagger.v3.parser.OpenAPIV3Parser +import wvlet.airframe.http.Router +import wvlet.airframe.http.codegen.HttpCodeGenerator +import wvlet.airspec.AirSpec + +/** + */ +class OpenAPITest extends AirSpec { + private val rpcRouter = Router.add[OpenAPIRPCExample] + private val endpointRouter = Router.add[OpenAPIEndpointExample] + + test("Generate OpenAPI from Router") { + val openapi = OpenAPI + .ofRouter(rpcRouter) + .withInfo(OpenAPI.Info(title = "RPCTest", version = "1.0")) + debug(openapi) + + openapi.info.title shouldBe "RPCTest" + openapi.info.version shouldBe "1.0" + + val json = openapi.toJSON + debug(s"Open API JSON:\n${json}\n") + parseOpenAPI(json) + + val yaml = openapi.toYAML + debug(s"Open API Yaml:\n${yaml}\n") + parseOpenAPI(yaml) + + // Naive tests for checking YAML fragments. + // We need to refine these fragments if we change OpenAPI format and model classes + val fragments = Seq( + """openapi: '3.0.3' + |info: + | title: RPCTest + | version: '1.0'""".stripMargin, + """paths:""", + """ requestBody: + | content: + | application/json: + | schema: + | type: object + | required: + | - p1 + | properties: + | p1: + | $ref: '#/components/schemas/example.openapi.OpenAPIRPCExample.RPCRequest'""".stripMargin, + """ responses: + | '200': + | description: 'RPC response' + | '400': + | $ref: '#/components/responses/400' + | '500': + | $ref: '#/components/responses/500' + | '503': + | $ref: '#/components/responses/503' + | tags: + | - rpc""".stripMargin, + """ responses: + | '200': + | description: 'RPC response' + | content: + | application/json: + | schema: + | $ref: '#/components/schemas/example.openapi.OpenAPIRPCExample.RPCResponse' + | application/x-msgpack: + | schema: + | $ref: '#/components/schemas/example.openapi.OpenAPIRPCExample.RPCResponse'""".stripMargin, + """ operationId: rpcWithOption + | requestBody: + | content: + | application/json: + | schema: + | type: object + | properties: + | p1: + | type: string + | application/x-msgpack: + | schema: + | type: object + | properties: + | p1: + | type: string + | required: true""".stripMargin, + """ /example.openapi.OpenAPIRPCExample/zeroAryRPC: + | post: + | summary: zeroAryRPC + | description: zeroAryRPC + | operationId: zeroAryRPC + | responses: + | '200': + | description: 'RPC response'""".stripMargin, + """components: + | schemas: + | example.openapi.OpenAPIRPCExample.RPCRequest: + | type: object + | required: + | - x1 + | - x2 + | - x3 + | - x4 + | - x5 + | - x6 + | - x7 + | - x8 + | properties: + | x1: + | type: integer + | format: int32 + | x2: + | type: integer + | format: int64 + | x3: + | type: boolean + | x4: + | type: number + | format: float + | x5: + | type: number + | format: double + | x6: + | type: array + | items: + | type: string + | x7: + | type: array + | items: + | type: string + | x8: + | type: object + | additionalProperties: + | type: string + | x9: + | type: integer + | format: int32""".stripMargin, + """ example.openapi.OpenAPIRPCExample.RPCResponse: + | type: object + | required: + | - y1 + | - y2 + | properties: + | y1: + | type: string + | y2: + | type: boolean""".stripMargin + ) + + fragments.foreach { x => + debug(s"checking ${x}") + yaml.contains(x) shouldBe true + } + } + + test(s"Generate OpenAPI spec from command line") { + val yaml = HttpCodeGenerator.generateOpenAPI(rpcRouter, "yaml", title = "My API", version = "1.0") + debug(yaml) + parseOpenAPI(yaml) + + val json = HttpCodeGenerator.generateOpenAPI(rpcRouter, "json", title = "My API", version = "1.0") + debug(json) + parseOpenAPI(json) + + intercept[IllegalArgumentException] { + HttpCodeGenerator.generateOpenAPI(rpcRouter, "invalid", title = "My API", version = "1.0") + } + } + + test("Generate OpenAPI spec from @Endpoint") { + val openapi = OpenAPI + .ofRouter(endpointRouter) + .withInfo(OpenAPI.Info(title = "EndpointTest", version = "1.0")) + debug(openapi) + + val json = openapi.toJSON + debug(json) + val yaml = openapi.toYAML + debug(yaml) + + val fragments = Seq( + """info: + | title: EndpointTest + | version: '1.0'""".stripMargin, + """ responses: + | '400': + | description: 'Bad Request' + | '500': + | description: 'Internal Server Error' + | '503': + | description: 'Service Unavailable'""".stripMargin, + """ /v1/get0: + | get: + | summary: get0 + | description: get0 + | operationId: get0 + |""".stripMargin, + """ /v1/get1/{id}: + | get: + | summary: get1 + | description: get1 + | operationId: get1 + | parameters: + | - name: id + | in: path + | required: true + | schema: + | type: integer + | format: int32""".stripMargin, + """ /v1/get2/{id}/{name}: + | get: + | summary: get2 + | description: get2 + | operationId: get2 + | parameters: + | - name: id + | in: path + | required: true + | schema: + | type: integer + | format: int32 + | - name: name + | in: path + | required: true + | schema: + | type: string""".stripMargin, + """ /v1/get3/{id}: + | get: + | summary: get3 + | description: get3 + | operationId: get3 + | parameters: + | - name: id + | in: path + | required: true + | schema: + | type: integer + | format: int32 + | - name: p1 + | in: query + | required: true + | schema: + | type: string""".stripMargin, + """ /v1/post1: + | post: + | summary: post1 + | description: post1 + | operationId: post1 + | responses: + | '200': + | description: 'RPC response' + | '400': + | $ref: '#/components/responses/400' + | '500': + | $ref: '#/components/responses/500' + | '503': + | $ref: '#/components/responses/503'""".stripMargin, + """ /v1/post2/{id}: + | post: + | summary: post2 + | description: post2 + | operationId: post2 + | parameters: + | - name: id + | in: path + | required: true + | schema: + | type: integer + | format: int32 + | requestBody: + | content: + | application/json: + | schema: + | type: object + | required: + | - id""".stripMargin, + """ /v1/post4/{id}: + | post: + | summary: post4 + | description: post4 + | operationId: post4 + | parameters: + | - name: id + | in: path + | required: true + | schema: + | type: integer + | format: int32 + | requestBody: + | content: + | application/json: + | schema: + | type: object + | required: + | - id + | - p1 + | properties: + | p1: + | type: string""".stripMargin, + """ /v1/post5: + | post: + | summary: post5 + | description: post5 + | operationId: post5 + | requestBody: + | content: + | application/json: + | schema: + | type: object + | required: + | - p1 + | properties: + | p1: + | $ref: '#/components/schemas/example.openapi.OpenAPIEndpointExample.EndpointRequest' + | application/x-msgpack: + | schema: + | type: object + | required: + | - p1 + | properties: + | p1: + | $ref: '#/components/schemas/example.openapi.OpenAPIEndpointExample.EndpointRequest' + | required: true + | responses: + | '200': + | description: 'RPC response' + | content: + | application/json: + | schema: + | $ref: '#/components/schemas/example.openapi.OpenAPIEndpointExample.EndpointResponse' + | application/x-msgpack: + | schema: + | $ref: '#/components/schemas/example.openapi.OpenAPIEndpointExample.EndpointResponse'""".stripMargin, + """ /v1/post6/{id}: + | post: + | summary: post6 + | description: post6 + | operationId: post6 + | parameters: + | - name: id + | in: path + | required: true + | schema: + | type: integer + | format: int32 + | requestBody: + | content: + | application/json: + | schema: + | type: object + | required: + | - id + | - p1 + | properties: + | p1: + | $ref: '#/components/schemas/example.openapi.OpenAPIEndpointExample.EndpointRequest' + | application/x-msgpack: + | schema: + | type: object + | required: + | - id + | - p1 + | properties: + | p1: + | $ref: '#/components/schemas/example.openapi.OpenAPIEndpointExample.EndpointRequest' + | required: true + | responses: + | '200': + | description: 'RPC response' + | content: + | application/json: + | schema: + | $ref: '#/components/schemas/example.openapi.OpenAPIEndpointExample.EndpointResponse' + | application/x-msgpack: + | schema: + | $ref: '#/components/schemas/example.openapi.OpenAPIEndpointExample.EndpointResponse'""".stripMargin, + """ example.openapi.OpenAPIEndpointExample.EndpointRequest: + | type: object + | required: + | - x1 + | - x2 + | - x3 + | - x4 + | - x5 + | - x6 + | - x7 + | - x8 + | properties: + | x1: + | type: integer + | format: int32 + | x2: + | type: integer + | format: int64 + | x3: + | type: boolean + | x4: + | type: number + | format: float + | x5: + | type: number + | format: double + | x6: + | type: array + | items: + | type: string + | x7: + | type: array + | items: + | type: string + | x8: + | type: object + | additionalProperties: + | type: string + | x9: + | type: integer + | format: int32""".stripMargin, + """ example.openapi.OpenAPIEndpointExample.EndpointResponse: + | type: object + | required: + | - y1 + | - y2 + | properties: + | y1: + | type: string + | y2: + | type: boolean""".stripMargin, + """ /v1/put1: + | put: + | summary: put1 + | description: put1 + | operationId: put1""".stripMargin, + """ /v1/delete1: + | delete: + | summary: delete1 + | description: delete1 + | operationId: delete1""".stripMargin, + """ /v1/patch1: + | patch: + | summary: patch1 + | description: patch1 + | operationId: patch1""".stripMargin, + """ /v1/head1: + | head: + | summary: head1 + | description: head1 + | operationId: head1""".stripMargin, + """ /v1/options1: + | options: + | summary: options1 + | description: options1 + | operationId: options1""".stripMargin, + """ /v1/trace1: + | trace: + | summary: trace1 + | description: trace1 + | operationId: trace1""".stripMargin + ) + + // For the ease of testing at https://editor.swagger.io/ + //java.awt.Toolkit.getDefaultToolkit.getSystemClipboard + // .setContents(new java.awt.datatransfer.StringSelection(yaml), null) + + fragments.foreach { x => + trace(x) + yaml.contains(x) shouldBe true + } + + // Parsing test + OpenAPI.parseJson(json) + parseOpenAPI(yaml) + } + + private def parseOpenAPI(yamlOrJson: String): io.swagger.v3.oas.models.OpenAPI = { + new OpenAPIV3Parser().readContents(yamlOrJson).getOpenAPI + } +} diff --git a/airframe-json/src/main/scala/wvlet/airframe/json/JSON.scala b/airframe-json/src/main/scala/wvlet/airframe/json/JSON.scala index 5b9e325b83..652f230916 100644 --- a/airframe-json/src/main/scala/wvlet/airframe/json/JSON.scala +++ b/airframe-json/src/main/scala/wvlet/airframe/json/JSON.scala @@ -104,7 +104,8 @@ object JSON extends LogSupport { } final case class JSONObject(v: Seq[(String, JSONValue)]) extends JSONValue { - def size: Int = v.size + def isEmpty: Boolean = v.isEmpty + def size: Int = v.size override def toJSON: String = { val s = new StringBuilder s.append("{") diff --git a/airframe-json/src/main/scala/wvlet/airframe/json/JSONTraverser.scala b/airframe-json/src/main/scala/wvlet/airframe/json/JSONTraverser.scala index 7e81047dd8..0943539fd9 100644 --- a/airframe-json/src/main/scala/wvlet/airframe/json/JSONTraverser.scala +++ b/airframe-json/src/main/scala/wvlet/airframe/json/JSONTraverser.scala @@ -17,8 +17,11 @@ import wvlet.airframe.json.JSON._ trait JSONVisitor { def visitObject(o: JSONObject): Unit = {} + def leaveObject(o: JSONObject): Unit = {} def visitKeyValue(k: String, v: JSONValue): Unit = {} + def leaveKeyValue(k: String, v: JSONValue): Unit = {} def visitArray(a: JSONArray): Unit = {} + def leaveArray(a: JSONArray): Unit = {} def visitString(v: JSONString): Unit = {} def visitNumber(n: JSONNumber): Unit = {} def visitBoolean(n: JSONBoolean): Unit = {} @@ -28,6 +31,10 @@ trait JSONVisitor { /** */ object JSONTraverser { + def traverse(json: String, visitor: JSONVisitor): Unit = { + traverse(JSON.parse(json), visitor) + } + def traverse(json: JSONValue, visitor: JSONVisitor): Unit = { json match { case o: JSONObject => @@ -35,10 +42,13 @@ object JSONTraverser { for ((jk, jv) <- o.v) { visitor.visitKeyValue(jk, jv) traverse(jv, visitor) + visitor.leaveKeyValue(jk, jv) } + visitor.leaveObject(o) case a: JSONArray => visitor.visitArray(a) a.v.foreach(traverse(_, visitor)) + visitor.leaveArray(a) case v: JSONString => visitor.visitString(v) case v: JSONNumber => diff --git a/airframe-json/src/main/scala/wvlet/airframe/json/YAMLFormatter.scala b/airframe-json/src/main/scala/wvlet/airframe/json/YAMLFormatter.scala new file mode 100644 index 0000000000..28bb43e13e --- /dev/null +++ b/airframe-json/src/main/scala/wvlet/airframe/json/YAMLFormatter.scala @@ -0,0 +1,154 @@ +package wvlet.airframe.json +import wvlet.airframe.json.JSON._ + +/** + * Convert JSON as Yaml + */ +object YAMLFormatter { + def toYaml(json: String): String = { + val visitor = new YamlWriter + JSONTraverser.traverse(json, visitor) + visitor.toYaml + } + + private sealed trait YamlContext { + def getAndAdd: Int + } + private case class OBJECT(private var count: Int) extends YamlContext { + def getAndAdd: Int = { + val v = count + count += 1 + v + } + } + private case class OBJECT_ARRAY(private var count: Int) extends YamlContext { + def getAndAdd: Int = { + val v = count + count += 1 + v + } + } + private case class ARRAY(private var count: Int) extends YamlContext { + def getAndAdd: Int = { + val v = count + count += 1 + v + } + } + + class YamlWriter() extends JSONVisitor { + private val lines = Seq.newBuilder[String] + def toYaml: String = lines.result().mkString("\n") + private var contextStack: List[YamlContext] = Nil + + private def indent: String = { + " " * (contextStack.length - 1) + } + + private def emitKey(k: String): Unit = { + lines += s"${indent}${quoteKey(k)}:" + } + private def emitKeyValue(k: String, v: JSONValue): Unit = { + lines += s"${indent}${quoteKey(k)}: ${quoteValue(v)}" + } + private def emitArrayKeyValue(k: String, v: JSONValue): Unit = { + lines += s"${" " * (contextStack.length - 2)}- ${quoteKey(k)}: ${quoteValue(v)}" + } + private def emitArrayElement(v: JSONValue): Unit = { + lines += s"${indent}- ${quoteValue(v)}" + } + private def quoteKey(k: String): String = { + def isNumber(k: String): Boolean = { + k.forall { + case '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' => true + case _ => false + } + } + + if (isNumber(k)) + s"'${k}'" + else + k + } + + private def quoteValue(v: JSONValue): String = { + val letterPattern = """[\w]+""".r + def isLetter(s: String): Boolean = { + s match { + case letterPattern() => true + case _ => false + } + } + + v match { + case s: JSONString if !isLetter(s.toString) => + s"'${s.toString}'" + case other => + v.toString + } + } + + override def visitObject(o: JSON.JSONObject): Unit = { + contextStack.headOption match { + case Some(a: ARRAY) => + contextStack = OBJECT_ARRAY(0) :: contextStack + case _ => + contextStack = OBJECT(0) :: contextStack + } + } + override def leaveObject(o: JSON.JSONObject): Unit = { + contextStack = contextStack.tail + contextStack.headOption.map(_.getAndAdd) + } + + private def isPrimitive(v: JSON.JSONValue): Boolean = { + v match { + case o: JSONObject => false + case a: JSONArray => false + case _ => true + } + } + + override def visitKeyValue(k: String, v: JSON.JSONValue): Unit = { + if (isPrimitive(v)) { + contextStack.head match { + case OBJECT_ARRAY(0) => + // The first object element inside array should have `-` prefix + emitArrayKeyValue(k, v) + case _ => + emitKeyValue(k, v) + } + } else { + v match { + case o: JSONObject if o.isEmpty => + // do not output empty key-value pair + case _ => + emitKey(k) + } + } + } + override def leaveKeyValue(k: String, v: JSON.JSONValue): Unit = {} + + override def visitArray(a: JSON.JSONArray): Unit = { + contextStack = ARRAY(0) :: contextStack + } + override def leaveArray(a: JSONArray): Unit = { + contextStack = contextStack.tail + contextStack.headOption.map(_.getAndAdd) + } + + private def emitPrimitive(v: JSONValue): Unit = { + contextStack.head match { + case a: ARRAY => + emitArrayElement(v) + case _ => + } + contextStack.headOption.map(_.getAndAdd) + } + override def visitString(v: JSONString): Unit = emitPrimitive(v) + override def visitNumber(n: JSONNumber): Unit = emitPrimitive(n) + override def visitBoolean(n: JSONBoolean): Unit = emitPrimitive(n) + override def visitNull: Unit = emitPrimitive(JSONNull) + } + +} diff --git a/airframe-json/src/test/scala/wvlet/airframe/json/YAMLFormatterTest.scala b/airframe-json/src/test/scala/wvlet/airframe/json/YAMLFormatterTest.scala new file mode 100644 index 0000000000..58284051f8 --- /dev/null +++ b/airframe-json/src/test/scala/wvlet/airframe/json/YAMLFormatterTest.scala @@ -0,0 +1,102 @@ +package wvlet.airframe.json + +import wvlet.airspec.AirSpec + +case class Test(json: String, yaml: String) + +class YAMLFormatterTest extends AirSpec { + + val testData = Seq( + // json, yaml + Test("""{}""", """"""), + Test("""{"a":1}""", """a: 1"""), + Test("""{"a":true}""", """a: true"""), + Test("""{"a":false}""", """a: false"""), + Test("""{"a":null}""", """a: null"""), + Test("""{"a":1.1}""", """a: 1.1"""), + Test("""{"a":"hello"}""", """a: hello"""), + Test("""{"a":"hello world"}""", """a: 'hello world'"""), + Test( + json = """{"a":[1, 2]}""", + yaml = """a: + | - 1 + | - 2""".stripMargin + ), + Test( + json = """{"a":[true, false]}""", + yaml = """a: + | - true + | - false""".stripMargin + ), + Test( + json = """{"a":[1, true, false, null, 1.1, "hello", "hello world"]}""", + yaml = """a: + | - 1 + | - true + | - false + | - null + | - 1.1 + | - hello + | - 'hello world'""".stripMargin + ), + Test( + json = """{"a":{}}""", + yaml = """""".stripMargin + ), + Test( + json = """{"a":{"b":1}}""", + yaml = """a: + | b: 1""".stripMargin + ), + Test( + json = """{"a":{"b":1, "c":true}}""", + yaml = """a: + | b: 1 + | c: true""".stripMargin + ), + Test( + json = """{"a":{"b":1, "c":true, "d":[1, 2, 3], "e":{"f":1}}}""", + yaml = """a: + | b: 1 + | c: true + | d: + | - 1 + | - 2 + | - 3 + | e: + | f: 1""".stripMargin + ), + Test( + json = """{"a":{"b":{"c":{"d":[1, 2, 3]}}}}""", + yaml = """a: + | b: + | c: + | d: + | - 1 + | - 2 + | - 3""".stripMargin + ), + Test( + json = """{"200":"ok"}}""", + yaml = """'200': ok""".stripMargin + ), + Test( + json = """{"a":[{"b":1, "c":2}, {"b":3, "c":4}]}""", + yaml = """a: + | - b: 1 + | c: 2 + | - b: 3 + | c: 4""".stripMargin + ) + ) + + test("format json into YAML") { + testData.foreach { x => + val json = x.json + val expectedYaml = x.yaml + + val yaml = YAMLFormatter.toYaml(json) + yaml shouldBe expectedYaml + } + } +} diff --git a/airframe-surface/jvm/src/main/scala/wvlet/airframe/surface/reflect/ReflectSurfaceFactory.scala b/airframe-surface/jvm/src/main/scala/wvlet/airframe/surface/reflect/ReflectSurfaceFactory.scala index 7e585e533f..3c8a0e59c4 100644 --- a/airframe-surface/jvm/src/main/scala/wvlet/airframe/surface/reflect/ReflectSurfaceFactory.scala +++ b/airframe-surface/jvm/src/main/scala/wvlet/airframe/surface/reflect/ReflectSurfaceFactory.scala @@ -306,7 +306,7 @@ object ReflectSurfaceFactory extends LogSupport { surfaceCache(fullName) } else if (seen.contains(tpe)) { // Recursive type - LazySurface(resolveClass(tpe), fullName, typeArgsOf(tpe).map(x => surfaceOf(x))) + LazySurface(resolveClass(tpe), fullName) } else { seen += tpe val m = surfaceFactories.orElse[ru.Type, Surface] { diff --git a/airframe-surface/shared/src/main/scala/wvlet/airframe/surface/SurfaceMacros.scala b/airframe-surface/shared/src/main/scala/wvlet/airframe/surface/SurfaceMacros.scala index 26c044b56d..3cae2aaa73 100644 --- a/airframe-surface/shared/src/main/scala/wvlet/airframe/surface/SurfaceMacros.scala +++ b/airframe-surface/shared/src/main/scala/wvlet/airframe/surface/SurfaceMacros.scala @@ -628,8 +628,7 @@ private[surface] object SurfaceMacros { if (memo.contains(t)) { memo(t) } else { - val typeArgs = typeArgsOf(t).map(surfaceOf(_)) - q"wvlet.airframe.surface.LazySurface(classOf[${t}], ${fullTypeNameOf(t)}, IndexedSeq(..$typeArgs))" + q"wvlet.airframe.surface.LazySurface(classOf[${t}], ${fullTypeNameOf(t)})" } } else { seen += t diff --git a/airframe-surface/shared/src/main/scala/wvlet/airframe/surface/Surfaces.scala b/airframe-surface/shared/src/main/scala/wvlet/airframe/surface/Surfaces.scala index b55cef931e..bb0d7059df 100644 --- a/airframe-surface/shared/src/main/scala/wvlet/airframe/surface/Surfaces.scala +++ b/airframe-surface/shared/src/main/scala/wvlet/airframe/surface/Surfaces.scala @@ -237,7 +237,7 @@ class GenericSurface( * Surface placeholder for supporting recursive types * @param rawType */ -case class LazySurface(rawType: Class[_], fullName: String, typeArgs: Seq[Surface]) extends Surface { +case class LazySurface(rawType: Class[_], fullName: String) extends Surface { // Resolved the final type from the full surface name protected def ref: Surface = wvlet.airframe.surface.getCached(fullName) @@ -251,6 +251,7 @@ case class LazySurface(rawType: Class[_], fullName: String, typeArgs: Seq[Surfac override def toString: String = name override def params = ref.params + override def typeArgs: Seq[Surface] = ref.typeArgs override def isOption = ref.isOption override def isAlias = ref.isAlias override def isPrimitive = ref.isPrimitive diff --git a/airframe-surface/shared/src/main/scala/wvlet/airframe/surface/Union.scala b/airframe-surface/shared/src/main/scala/wvlet/airframe/surface/Union.scala new file mode 100644 index 0000000000..a5dc7afbf5 --- /dev/null +++ b/airframe-surface/shared/src/main/scala/wvlet/airframe/surface/Union.scala @@ -0,0 +1,27 @@ +/* + * 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 wvlet.airframe.surface + +/** + * Union types + */ +trait Union { + + /** + * Return the element class + */ + def getElementClass: Class[_] +} +trait Union2[A, B] extends Union +trait Union3[A, B, C] extends Union diff --git a/build.sbt b/build.sbt index d9b66618bd..469aecee0d 100644 --- a/build.sbt +++ b/build.sbt @@ -578,9 +578,15 @@ lazy val http = lazy val httpJVM = http.jvm .enablePlugins(PackPlugin) .settings( - packMain := Map("airframe-http-client-generator" -> "wvlet.airframe.http.codegen.HttpClientGenerator"), + packMain := Map("airframe-http-code-generator" -> "wvlet.airframe.http.codegen.HttpCodeGenerator"), packExcludeLibJars := Seq("airspec_2.12"), - publishPackArchiveTgz + publishPackArchiveTgz, + libraryDependencies ++= Seq( + // Use swagger-parser only for validating YAML format in tests + "io.swagger.parser.v3" % "swagger-parser" % "2.0.20" % Test, + // Swagger includes dependency to SLF4J, so redirect slf4j logs to airframe-log + "org.slf4j" % "slf4j-jdk14" % SLF4J_VERSION % Test + ) ).dependsOn(launcher) lazy val httpJS = http.js diff --git a/docs/airframe-rpc.md b/docs/airframe-rpc.md index 3d7e9c06a5..bbebe38717 100644 --- a/docs/airframe-rpc.md +++ b/docs/airframe-rpc.md @@ -223,6 +223,32 @@ The generated client code can be found in `target/scala-2.12/src_managed/(api pa > airframeHttpClean # Clean the generated code ``` +### Open API + +sbt-airframe plugin also supports generating [Open API](http://spec.openapis.org/oas/v3.0.3) specification from Airframe RPC interfaces. +To generate OpenAPI spec from RPC definition, add `airframeHttpOpenAPIPackages` configuration to your build.sbt: + +```scala +// [Required] RPC packages to use for generating Open API specification +airframeHttpOpenAPIPackages := Seq("hello.api") +// [Optional] Specify target directory to generate openapi.yaml. The default is target directory +airframeHttpOpenAPITargetDir := target.value +// [Optional] Additional configurations (e.g., title, version, etc.) +airframeHttpOpenAPIConfig := OpenAPIConfig( + title = "My API", // default is project name + version = "1.0.0", // default is project version, + format = "yaml", // yaml (default) or json + filePrefix = "openapi" // Output file name: (filePrefix).(format) +) +``` + +With this configuration, Open API spec will be generated when running `package` task: +```scala +> package +``` + +It will generate `target/openapi.yaml` file. + ### RPC Logging Airframe RPC stores HTTP access logs to `log/http-access.json` by default. This json logs contains @@ -383,4 +409,6 @@ case class HelloResponse(message:String) ```json {"message":"..."} ``` - +-__Http Status__ + - 200 (Ok) for successful responses. + - 400 (Bad Request) if some request parameters are invalid. diff --git a/sbt-airframe/src/main/scala/wvlet/airframe/sbt/http/AirframeHttpPlugin.scala b/sbt-airframe/src/main/scala/wvlet/airframe/sbt/http/AirframeHttpPlugin.scala index aaa16365aa..31344aadca 100644 --- a/sbt-airframe/src/main/scala/wvlet/airframe/sbt/http/AirframeHttpPlugin.scala +++ b/sbt-airframe/src/main/scala/wvlet/airframe/sbt/http/AirframeHttpPlugin.scala @@ -25,6 +25,7 @@ import wvlet.airframe.codec.MessageCodec import wvlet.airframe.control.OS import wvlet.log.LogSupport import wvlet.log.io.IOUtil.withResource +import scala.sys.process._ /** * sbt plugin for supporting Airframe HTTP development. @@ -57,9 +58,16 @@ object AirframeHttpPlugin extends AutoPlugin with LogSupport { val airframeHttpGeneratorOption = settingKey[String]("airframe-http client-generator options") val airframeHttpClean = taskKey[Unit]("clean artifacts") val airframeHttpClasspass = taskKey[Seq[String]]("class loader for dependent classes") - val airframeHttpBinaryDir = taskKey[File]("Downloaded Airframe HTTP Binary location") + val airframeHttpBinaryDir = taskKey[File]("Download Airframe HTTP binary to this location") val airframeHttpVersion = settingKey[String]("airframe-http version to use") val airframeHttpReload = taskKey[Seq[File]]("refresh generated clients") + val airframeHttpOpts = settingKey[String]("additional option for airframe-http commands") + + // Keys for OpenAPI spec generator + val airframeHttpOpenAPIConfig = settingKey[OpenAPIConfig]("OpenAPI spec generator configuration") + val airframeHttpOpenAPIPackages = settingKey[Seq[String]]("OpenAPI target API package names") + val airframeHttpOpenAPITargetDir = settingKey[File]("OpenAPI spec file target folder") + val airframeHttpOpenAPIGenerate = taskKey[Seq[File]]("Generate OpenAPI spec from RPC definition") } private def dependentProjects: ScopeFilter = @@ -77,8 +85,10 @@ object AirframeHttpPlugin extends AutoPlugin with LogSupport { (compile in Compile).all(dependentProjects).value val baseDir = (ThisBuild / baseDirectory).value - val classpaths = (dependencyClasspath in Compile).value.files - .map { p => p.relativeTo(baseDir).getOrElse(p).getPath } + val classpaths = + ((Compile / dependencyClasspath).value.files :+ (Compile / classDirectory).value) + .map { p => p.relativeTo(baseDir).getOrElse(p).getPath } + classpaths }, airframeHttpWorkDir := (Compile / target).value / s"scala-${scalaBinaryVersion.value}" / s"airframe" / airframeHttpVersion.value, @@ -90,6 +100,8 @@ object AirframeHttpPlugin extends AutoPlugin with LogSupport { }, airframeHttpVersion := wvlet.airframe.sbt.BuildInfo.version, airframeHttpBinaryDir := { + // This task is for downloading airframe-http library to parse Airframe HTTP/RPC interfaces using a forked JVM. + // Without forking JVM, sbt's class loader cannot load @RPC and @Endpoint annotations. val airframeVersion = airframeHttpVersion.value val airframeHttpPackageDir = airframeHttpWorkDir.value / "local" @@ -176,21 +188,15 @@ object AirframeHttpPlugin extends AutoPlugin with LogSupport { val cacheFile = targetDir / cacheFileName val binDir = airframeHttpBinaryDir.value val cp = airframeHttpClasspass.value.mkString(":") - val opts = airframeHttpGeneratorOption.value + val opts = s"${airframeHttpOpts.value} ${airframeHttpGeneratorOption.value}" val result: Seq[File] = if (!cacheFile.exists) { debug(s"airframe-http directory: ${binDir}") val outDir: String = (Compile / sourceManaged).value.getPath - val cmdName = if (OS.isWindows) { - "airframe-http-client-generator.bat" - } else { - "airframe-http-client-generator" - } val cmd = - s"${binDir}/bin/${cmdName} generate ${opts} -cp ${cp} -o ${outDir} -t ${targetDir.getPath} ${airframeHttpClients.value + s"${binDir}/bin/${generatorName} generate ${opts} -cp ${cp} -o ${outDir} -t ${targetDir.getPath} ${airframeHttpClients.value .mkString(" ")}" debug(cmd) - import scala.sys.process._ val json: String = cmd.!! debug(s"client generator result: ${json}") IO.write(cacheFile, json) @@ -203,9 +209,68 @@ object AirframeHttpPlugin extends AutoPlugin with LogSupport { } result }, + airframeHttpOpts := "", + airframeHttpOpenAPIConfig := OpenAPIConfig( + title = name.value, + version = version.value + ), + airframeHttpOpenAPITargetDir := target.value, + airframeHttpOpenAPIPackages := Seq.empty, + airframeHttpOpenAPIGenerate := Def + .task { + val config = airframeHttpOpenAPIConfig.value + val formatType: String = config.format + val outFile: File = airframeHttpOpenAPITargetDir.value / s"${config.filePrefix}.${formatType}" + val binDir: File = airframeHttpBinaryDir.value + val cp = airframeHttpClasspass.value.mkString(":") + val packages = airframeHttpOpenAPIPackages.value + val opts = airframeHttpOpts.value + if (packages.isEmpty) { + Seq.empty + } else { + // Build command line manally because scala.sys.proces cannot parse quoted strings + val cmd = Seq.newBuilder[String] + cmd += s"${binDir}/bin/${generatorName}" + cmd += "openapi" + if (opts.nonEmpty) { + cmd ++= opts.split("\\s+") + } + cmd ++= Seq( + "-cp", + cp, + "-f", + formatType, + "-o", + outFile.getPath, + "--title", + config.title, + "--version", + config.version + ) + cmd ++= packages + + val cmdline = cmd.result() + info(cmdline) + Process(cmdline).!! + Seq(outFile) + } + }.dependsOn(Compile / compile).value, + // Generate HTTP clients before compilation Compile / sourceGenerators += Def.task { airframeHttpGenerateClient.value - }.taskValue + }.taskValue, + // Generate OpenAPI doc when generating package + Compile / `package` := (Compile / `package`).dependsOn(airframeHttpOpenAPIGenerate).value ) } + + private def generatorName = { + val cmdName = if (OS.isWindows) { + "airframe-http-code-generator.bat" + } else { + "airframe-http-code-generator" + } + cmdName + } + } diff --git a/sbt-airframe/src/main/scala/wvlet/airframe/sbt/http/OpenAPIConfig.scala b/sbt-airframe/src/main/scala/wvlet/airframe/sbt/http/OpenAPIConfig.scala new file mode 100644 index 0000000000..9e25ba1405 --- /dev/null +++ b/sbt-airframe/src/main/scala/wvlet/airframe/sbt/http/OpenAPIConfig.scala @@ -0,0 +1,23 @@ +/* + * 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 wvlet.airframe.sbt.http + +/** + */ +case class OpenAPIConfig( + title: String, + version: String, + format: String = "yaml", + filePrefix: String = "openapi" +) diff --git a/sbt-airframe/src/sbt-test/sbt-airframe/generate-client/project/build.properties b/sbt-airframe/src/sbt-test/sbt-airframe/generate-client/project/build.properties deleted file mode 100644 index 742d2e0442..0000000000 --- a/sbt-airframe/src/sbt-test/sbt-airframe/generate-client/project/build.properties +++ /dev/null @@ -1 +0,0 @@ -sbt.version=1.3.11 diff --git a/sbt-airframe/src/sbt-test/sbt-airframe/js-client/project/build.properties b/sbt-airframe/src/sbt-test/sbt-airframe/js-client/project/build.properties deleted file mode 100644 index b080286102..0000000000 --- a/sbt-airframe/src/sbt-test/sbt-airframe/js-client/project/build.properties +++ /dev/null @@ -1,14 +0,0 @@ -# -# 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. -# -sbt.version=1.3.11 diff --git a/sbt-airframe/src/sbt-test/sbt-airframe/openapi/build.sbt b/sbt-airframe/src/sbt-test/sbt-airframe/openapi/build.sbt new file mode 100644 index 0000000000..5ad0fb358a --- /dev/null +++ b/sbt-airframe/src/sbt-test/sbt-airframe/openapi/build.sbt @@ -0,0 +1,27 @@ +enablePlugins(AirframeHttpPlugin) + +name := "Open API Test" +version := "1.0.0" + +airframeHttpOpenAPIPackages := Seq("example.api") +airframeHttpOpts := "-l debug" +libraryDependencies ++= Seq( + "org.wvlet.airframe" %% "airframe-http" % sys.props("plugin.version") +) + +TaskKey[Unit]("check") := { + val yaml = IO.read(target.value / "openapi.yaml") + val expected = Seq( + "title: 'Open API Test'", + "version: '1.0.0'", + "/example.api.OpenAPIRPCExample/rpcWithPrimitiveAndOption:", + "/example.api.OpenAPIRPCExample/rpcWithPrimitive:", + "$ref: '#/components/schemas/example.api.OpenAPIRPCExample.RPCRequest'", + "example.api.OpenAPIRPCExample.RPCRequest:" + ) + expected.foreach { x => + if (!yaml.contains(x)) { + sys.error(s"Generated YAML file doesn't contain line: ${x}") + } + } +} diff --git a/sbt-airframe/src/sbt-test/sbt-airframe/openapi/project/plugins.sbt b/sbt-airframe/src/sbt-test/sbt-airframe/openapi/project/plugins.sbt new file mode 100644 index 0000000000..5b2e740902 --- /dev/null +++ b/sbt-airframe/src/sbt-test/sbt-airframe/openapi/project/plugins.sbt @@ -0,0 +1,5 @@ +sys.props.get("plugin.version") match { + case Some(x) => addSbtPlugin("org.wvlet.airframe" % "sbt-airframe" % x) + case _ => sys.error("""|The system property 'plugin.version' is not defined. + |Specify this property using the scriptedLaunchOpts -D.""".stripMargin) +} diff --git a/sbt-airframe/src/sbt-test/sbt-airframe/openapi/src/main/scala/example/api/RPCExample.scala b/sbt-airframe/src/sbt-test/sbt-airframe/openapi/src/main/scala/example/api/RPCExample.scala new file mode 100644 index 0000000000..b876bf9a24 --- /dev/null +++ b/sbt-airframe/src/sbt-test/sbt-airframe/openapi/src/main/scala/example/api/RPCExample.scala @@ -0,0 +1,46 @@ +/* + * 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 example.api +import wvlet.airframe.http.RPC + +/** + */ +@RPC +trait OpenAPIRPCExample { + import OpenAPIRPCExample._ + + def zeroAryRPC: Unit + def rpcWithPrimitive(p1: Int): Int + def rpcWithMultiplePrimitives(p1: Int, p2: String): Int + def rpcWithComplexParam(p1: RPCRequest): RPCResponse + def rpcWithMultipleParams(p1: Int, p2: RPCRequest): RPCResponse + def rpcWithOption(p1: Option[String]): Unit + def rpcWithPrimitiveAndOption(p1: String, p2: Option[String]): Unit + def rpcWithOptionOfComplexType(p1: Option[RPCRequest]): Unit +} + +object OpenAPIRPCExample { + case class RPCRequest( + x1: Int, + x2: Long, + x3: Boolean, + x4: Float, + x5: Double, + x6: Array[String], + x7: Seq[String], + x8: Map[String, Any], + x9: Option[Int] = None + ) + case class RPCResponse(y1: String, y2: Boolean) +} diff --git a/sbt-airframe/src/sbt-test/sbt-airframe/openapi/test b/sbt-airframe/src/sbt-test/sbt-airframe/openapi/test new file mode 100644 index 0000000000..735267bac6 --- /dev/null +++ b/sbt-airframe/src/sbt-test/sbt-airframe/openapi/test @@ -0,0 +1,3 @@ +> package +$ exists target/openapi.yaml +> check