Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

airframe-rpc: Generate Open API schema from RPC interfaces #1178

Merged
merged 31 commits into from
Jul 22, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
0a7725d
Add OpenAPI model classes
xerial Jul 4, 2020
b683013
Add OpenAPI generator
xerial Jul 4, 2020
e196d2c
Add UnionCodec
xerial Jul 4, 2020
0220182
Resolve schema
xerial Jul 4, 2020
4a11ff4
Fix return type schema
xerial Jul 4, 2020
48ce488
Add requestBody
xerial Jul 4, 2020
27b8717
reference complex schemas
xerial Jul 4, 2020
5dcadc4
Add JSON to YAML converter
xerial Jul 6, 2020
d8e37e6
Use 200 responses for RPC requests
xerial Jul 8, 2020
2d615c0
Generate YAML from JSON
xerial Jul 9, 2020
5736e2b
Add sbt-airframe support for generating OpenAPI spec
xerial Jul 9, 2020
35e932c
Fix plugin settings
xerial Jul 9, 2020
cd74052
Use package for generating resources
xerial Jul 9, 2020
9401674
Include current project's class paths
xerial Jul 9, 2020
0cd2317
Add debug log
xerial Jul 9, 2020
a2734ed
Rename to HttpCodeGenerator
xerial Jul 9, 2020
ab10f17
Support title/version change
xerial Jul 9, 2020
523eb10
Add a simple YAML test
xerial Jul 9, 2020
7deef61
Set default value
xerial Jul 9, 2020
88f0ab6
Add YamlFormatter test
xerial Jul 9, 2020
09442e7
Add Endpoint test
xerial Jul 9, 2020
37d32a6
Add query parameter test
xerial Jul 9, 2020
8cb54af
Support query parameters
xerial Jul 9, 2020
9c050bd
Use JS compatible test examples
xerial Jul 9, 2020
8245555
Use swagger-parser for sanity check
xerial Jul 17, 2020
8ab2859
Use ListMap for preserving path order
xerial Jul 17, 2020
cccac20
Extract custom error responses
xerial Jul 17, 2020
e7904d0
Test all http methods
xerial Jul 17, 2020
d0d92dc
Merge branch 'master' into openapi
xerial Jul 17, 2020
40b3ccc
Fix test case
xerial Jul 17, 2020
a9514b1
Fix LazySurface genreator macro
xerial Jul 17, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
*/
Expand All @@ -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,
Expand Down Expand Up @@ -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") =>
Expand Down
Original file line number Diff line number Diff line change
@@ -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)}"
)
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 =>
Expand All @@ -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
Expand Down Expand Up @@ -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 _ =>
Expand All @@ -134,15 +137,15 @@ 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
} else {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Expand Down Expand Up @@ -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)
Expand All @@ -81,30 +81,56 @@ 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])
}

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")
logLevel: Option[LogLevel] = None
) 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")
Expand All @@ -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)
Expand All @@ -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 {
Expand All @@ -152,14 +176,41 @@ 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())
}
}

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()
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand All @@ -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
Expand Down
Loading