Hatena::ブログ(Diary)

CLOVER

2017-02-11

JAX-RS(RESTEasy/Undertow)で、Swaggerを使ってREST APIのドキュメントを生成する

Swaggerというものがあるのはなんとなく知っていたのですが、使ったことがなかったので
試してみます。

The Best APIs are Built with Swagger Tools | Swagger

no title

あんまりちゃんと調べたことなかったのですが、JSON/YAMLを使ってRESTful APIのドキュメンテーションと
コード、実際のAPIの間をもつ仕組みみたいですね。



今回は、SwaggerのJAX-RS用のモジュールを使ってSwagger Spec(JSON)を生成し、Swagger UIで見るところまで
やってみます。

参考)
no title

JAX-RS の REST API ドキュメントを Swagger を使って生成する - なにか作る

また、構成は

  • JAX-RS(RESTEasy)
  • Servlet Container(Undertow)
  • Scala + Jackson Scala Module

とします。

Swagger Coreおよび、SwaggerのJAX-RS用のモジュールはこちら。

no title

no title

Swagger JAX-RSについては、こちらの情報とサンプルを参考にしています。

no title

no title

準備

ビルド定義。
build.sbt

name := "resteasy-swagger"

version := "0.0.1-SNAPSHOT"

organization := "org.littlewings"

scalaVersion := "2.12.1"

scalacOptions ++= Seq("-Xlint", "-deprecation", "-unchecked", "-feature")

updateOptions := updateOptions.value.withCachedResolution(true)

libraryDependencies ++= Seq(
  "org.jboss.resteasy" % "resteasy-undertow" % "3.0.19.Final",
  "org.jboss.resteasy" % "resteasy-jackson2-provider" % "3.0.19.Final",
  "com.fasterxml.jackson.module" %% "jackson-module-scala" % "2.8.6",
  "io.undertow" % "undertow-core" % "1.4.10.Final",
  "io.undertow" % "undertow-servlet" % "1.4.10.Final",
  "io.swagger" % "swagger-jaxrs" % "1.5.12" exclude("javax.ws.rs", "jsr311-api")
)

Swagger関係のものは「swagger-jaxrs」ですが、JAX-RS 1系の依存が入っているようなので除去しています。
Undertowで動かす時は、これが残っていると困ったことになりました。

あとは、RESTEasyでJackson 2、Jackson 2のScala用のモジュール、Undertowに燗する依存関係です。

JAX-RSリソースクラス(REST API)の作成

まずは、対象となるJAX-RSリソースクラス(REST API)を作っていきます。

特にJSONにはこだわらない足し算、掛け算を行うリソースクラス。
src/main/scala/org/littlewings/javaee7/rest/CalcResource.scala

package org.littlewings.javaee7.rest

import javax.ws.rs.core.MediaType
import javax.ws.rs.{GET, Path, Produces, QueryParam}

import io.swagger.annotations.{Api, ApiOperation}

@Path("calc")
@Api("calc")
class CalcResource {
  @GET
  @Path("add")
  @Produces(Array(MediaType.TEXT_PLAIN))
  @ApiOperation(value = "calc add")
  def add(@QueryParam("a") a: Int, @QueryParam("b") b: Int): Int =
    a + b

  @GET
  @Path("multiply")
  @Produces(Array(MediaType.TEXT_PLAIN))
  @ApiOperation(value = "calc multiply")
  def multiply(@QueryParam("a") a: Int, @QueryParam("b") b: Int): Int =
    a * b
}

Swaggerに燗する@Api、@ApiOperationを付与しています。@ProducesでMediaTypeをきっちり指定しておくと
Swaggerが生成するSpecにも反映してくれます。

@Apiで指定しているのは、APIに対するタグになります。

@Api("calc")

続いて、JSONを扱うリソースクラス。お題は書籍とし、データはインメモリで持つこととします。
src/main/scala/org/littlewings/javaee7/rest/BookResource.scala

package org.littlewings.javaee7.rest

import javax.ws.rs.core.{Context, MediaType, Response, UriInfo}
import javax.ws.rs._

import io.swagger.annotations.{Api, ApiOperation}

import scala.collection.JavaConverters._

object BookResource {
  private[rest] val books: scala.collection.mutable.Map[String, Book] =
    new java.util.concurrent.ConcurrentHashMap[String, Book]().asScala
}

@Path("book")
@Api("book")
class BookResource {
  @GET
  @Produces(Array(MediaType.APPLICATION_JSON))
  @ApiOperation(value = "find all books", response = classOf[Seq[Book]])
  def fildAll: Seq[Book] =
    BookResource.books.values.toVector

  @GET
  @Path("{isbn}")
  @Produces(Array(MediaType.APPLICATION_JSON))
  @ApiOperation(value = "find book", response = classOf[Book])
  def find(@PathParam("isbn") isbn: String): Book =
    BookResource.books.get(isbn).orNull

  @PUT
  @Path("{isbn}")
  @Consumes(Array(MediaType.APPLICATION_JSON))
  @Produces(Array(MediaType.APPLICATION_JSON))
  @ApiOperation("register book")
  def register(book: Book, @Context uriInfo: UriInfo): Response = {
    BookResource.books.put(book.isbn, book)
    Response.created(uriInfo.getRequestUriBuilder.build(book.isbn)).build
  }
}

先ほどと少し変えているのは@ApiOperationのresponseにメソッドの戻り値の型を指定しているところで、ここを指定して
おくとSwaggerが生成するJSONの方にもこの情報が伝わるようになります。

  @GET
  @Path("{isbn}")
  @Produces(Array(MediaType.APPLICATION_JSON))
  @ApiOperation(value = "find book", response = classOf[Book])
  def find(@PathParam("isbn") isbn: String): Book =
    BookResource.books.get(isbn).orNull

また、BookクラスはCase Classとして作成しましたが、JavaBeans(getter/setter)でないとSwaggerが型を理解してくれない
みたいなので、@BeanPropertyを付与しています。

src/main/scala/org/littlewings/javaee7/rest/Book.scala 
package org.littlewings.javaee7.rest

import scala.beans.BeanProperty

case class Book(@BeanProperty isbn: String, @BeanProperty title: String, @BeanProperty price: Int)

ここまでで、JAX-RSリソースクラスの作成は完了です。

Swaggerの設定をする

SwaggerのJAX-RS用のモジュールでは、JAX-RSのApplicationクラスのサブクラス内にSwagger関係の設定を
実装します。

今回作成したのは、こちら。
src/main/scala/org/littlewings/javaee7/rest/JaxrsActivator.scala

package org.littlewings.javaee7.rest

import javax.ws.rs.{ApplicationPath, Path}
import javax.ws.rs.core.Application

import io.swagger.jaxrs.config.BeanConfig
import io.swagger.jaxrs.listing.{ApiListingResource, SwaggerSerializers}
import org.reflections.Reflections

import scala.collection.JavaConverters._

@ApplicationPath("api")
class JaxrsActivator extends Application {
  val beanConfig = new BeanConfig
  beanConfig.setVersion("1.0.0");
  beanConfig.setSchemes(Array("http"))
  beanConfig.setHost("localhost:8080")
  beanConfig.setBasePath(getClass.getAnnotation(classOf[ApplicationPath]).value)
  beanConfig.setResourcePackage(classOf[JaxrsActivator].getPackage.getName)
  beanConfig.setScan(true)

  override def getClasses: java.util.Set[Class[_]] = {
    val resourceClasses: Set[Class[_]] =
      Set.empty ++
        new Reflections(classOf[JaxrsActivator].getPackage.getName)
          .getTypesAnnotatedWith(classOf[Path])
          .asScala
    val swaggerClasses = Set[Class[_]](
      classOf[ApiListingResource],
      classOf[SwaggerSerializers]
    )

    (resourceClasses ++ swaggerClasses).asJava
  }
}

Swaggerが生成する対象のAPI群に燗する基本的な設定は、こちらで行います。

  val beanConfig = new BeanConfig
  beanConfig.setVersion("1.0.0");
  beanConfig.setSchemes(Array("http"))
  beanConfig.setHost("localhost:8080")
  beanConfig.setBasePath(getClass.getAnnotation(classOf[ApplicationPath]).value)
  beanConfig.setResourcePackage(classOf[JaxrsActivator].getPackage.getName)
  beanConfig.setScan(true)

basePathとresourcePackageは、ハードコードしてもよかったのですが、今回は@ApplicationPathの値を引っこ抜いたのと
作成したクラスを全部同じパッケージに置いているのでこんな感じではしょりました。

Application#getClassesでは、SwaggerのApiListingResourceクラスとSwaggerSerializersクラスを入れて返す
必要があります。

  override def getClasses: java.util.Set[Class[_]] = {
    val resourceClasses: Set[Class[_]] =
      Set.empty ++
        new Reflections(classOf[JaxrsActivator].getPackage.getName)
          .getTypesAnnotatedWith(classOf[Path])
          .asScala
    val swaggerClasses = Set[Class[_]](
      classOf[ApiListingResource],
      classOf[SwaggerSerializers]
    )

    (resourceClasses ++ swaggerClasses).asJava
  }

こうなると、自分が作成したリソースクラスも手動で登録することになるので、Reflectionsで
引っこ抜きました。

Reflectionsは、Swagger JAX-RSの依存関係に含まれています。

Jackson Scala Moduleの設定

Case Classを使ったり、SeqをJAX-RSリソースクラスで使ってしまっているので、Jackson 2に対するScala用のモジュールの
設定が必要になります。

こんなクラスを実装して
src/main/scala/org/littlewings/javaee7/rest/ScalaObjectMapperProvider.scala

package org.littlewings.javaee7.rest

import javax.ws.rs.{Consumes, Produces}
import javax.ws.rs.core.MediaType
import javax.ws.rs.ext.{ContextResolver, Provider}

import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.scala.DefaultScalaModule

@Provider
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
class ScalaObjectMapperProvider extends ContextResolver[ObjectMapper] {
  override def getContext(typ: Class[_]): ObjectMapper = {
    val objectMapper = new ObjectMapper
    objectMapper.registerModule(DefaultScalaModule)
    objectMapper
  }
}

Service Providerの設定を用意しておきます。
src/main/resources/META-INF/services/javax.ws.rs.ext.Providers

org.littlewings.javaee7.rest.ScalaObjectMapperProvider

起動クラス

最後に、起動用のクラスを作成します。UndertowにJAX-RSアプリケーションをデプロイします、と。
src/main/scala/org/littlewings/javaee7/rest/Server.scala

package org.littlewings.javaee7.rest

import java.time.LocalDateTime
import javax.servlet.DispatcherType

import io.undertow.Undertow
import io.undertow.servlet.Servlets
import io.undertow.servlet.api.FilterInfo
import org.jboss.resteasy.plugins.server.undertow.UndertowJaxrsServer

import scala.io.StdIn

object Server {
  def main(args: Array[String]): Unit = {
    val server = new UndertowJaxrsServer
    val deployment = server.undertowDeployment(classOf[JaxrsActivator])
    deployment.setContextPath("")
    deployment.setDeploymentName("resteasy-swagger")
    server.deploy(deployment)

    server.start(Undertow
            .builder
            .addHttpListener(8080, "localhost"))

    StdIn.readLine(s"[${LocalDateTime.now}] Server startup. enter, stop.")

    server.stop()
  }
}

起動すると、Enterを入力するまで浮いているサーバーになります。

確認

それでは、確認してみましょう。Undertowで作ったサーバーを起動します。

> run
[info] Running org.littlewings.javaee7.rest.Server 
SLF4J: Failed to load class "org.slf4j.impl.StaticLoggerBinder".
SLF4J: Defaulting to no-operation (NOP) logger implementation
SLF4J: See http://www.slf4j.org/codes.html#StaticLoggerBinder for further details.
2 11, 2017 9:13:32 午後 org.jboss.resteasy.spi.ResteasyDeployment processApplication
INFO: RESTEASY002225: Deploying javax.ws.rs.core.Application: class org.littlewings.javaee7.rest.JaxrsActivator
2 11, 2017 9:13:32 午後 org.jboss.resteasy.spi.ResteasyDeployment processApplication
INFO: RESTEASY002200: Adding class resource org.littlewings.javaee7.rest.CalcResource from Application class org.littlewings.javaee7.rest.JaxrsActivator
2 11, 2017 9:13:32 午後 org.jboss.resteasy.spi.ResteasyDeployment processApplication
INFO: RESTEASY002200: Adding class resource org.littlewings.javaee7.rest.BookResource from Application class org.littlewings.javaee7.rest.JaxrsActivator
2 11, 2017 9:13:32 午後 org.jboss.resteasy.spi.ResteasyDeployment processApplication
INFO: RESTEASY002200: Adding class resource io.swagger.jaxrs.listing.ApiListingResource from Application class org.littlewings.javaee7.rest.JaxrsActivator
2 11, 2017 9:13:32 午後 org.jboss.resteasy.spi.ResteasyDeployment processApplication
INFO: RESTEASY002205: Adding provider class io.swagger.jaxrs.listing.SwaggerSerializers from Application class org.littlewings.javaee7.rest.JaxrsActivator
2 11, 2017 9:13:32 午後 org.xnio.Xnio <clinit>
INFO: XNIO version 3.3.6.Final
2 11, 2017 9:13:32 午後 org.xnio.nio.NioXnio <clinit>
INFO: XNIO NIO Implementation Version 3.3.6.Final
[2017-02-11T21:13:33.221] Server startup. enter, stop.

curlで「http://localhost:8080/api/swagger.json」にアクセスすると、生成されたSwagger Specを確認することができます。
URLは、「コンテキストパスまで〜/[BeanConfig#basePath]/swagger.json」みたいですね。

$ curl -i 'http://localhost:8080/api/swagger.json'
HTTP/1.1 200 OK
Connection: keep-alive
Content-Type: application/json
Content-Length: 2087
Date: Sat, 11 Feb 2017 12:13:59 GMT

{"swagger":"2.0","info":{"version":"1.0.0"},"host":"localhost:8080","basePath":"/api","tags":[{"name":"book"},{"name":"calc"}],"schemes":["http"],"paths":{"/book":{"get":{"tags":["book"],"summary":"find all books","description":"","operationId":"fildAll","produces":["application/json"],"parameters":[],"responses":{"200":{"description":"successful operation","schema":{"$ref":"#/definitions/Seq"}}}}},"/book/{isbn}":{"get":{"tags":["book"],"summary":"find book","description":"","operationId":"find","produces":["application/json"],"parameters":[{"name":"isbn","in":"path","required":true,"type":"string"}],"responses":{"200":{"description":"successful operation","schema":{"$ref":"#/definitions/Book"}}}},"put":{"tags":["book"],"summary":"register book","description":"","operationId":"register","consumes":["application/json"],"produces":["application/json"],"parameters":[{"in":"body","name":"body","required":false,"schema":{"$ref":"#/definitions/Book"}}],"responses":{"default":{"description":"successful operation"}}}},"/calc/multiply":{"get":{"tags":["calc"],"summary":"calc multiply","description":"","operationId":"multiply","produces":["text/plain"],"parameters":[{"name":"a","in":"query","required":false,"type":"integer","format":"int32"},{"name":"b","in":"query","required":false,"type":"integer","format":"int32"}],"responses":{"200":{"description":"successful operation","schema":{"type":"integer","format":"int32"}}}}},"/calc/add":{"get":{"tags":["calc"],"summary":"calc add","description":"","operationId":"add","produces":["text/plain"],"parameters":[{"name":"a","in":"query","required":false,"type":"integer","format":"int32"},{"name":"b","in":"query","required":false,"type":"integer","format":"int32"}],"responses":{"200":{"description":"successful operation","schema":{"type":"integer","format":"int32"}}}}}},"definitions":{"Seq":{"type":"object","properties":{"traversableAgain":{"type":"boolean"},"empty":{"type":"boolean"}}},"Book":{"type":"object","properties":{"isbn":{"type":"string"},"title":{"type":"string"},"price":{"type":"integer","format":"int32"}}}}}

Swagger UIを使う

でも、これだとよくわからないのでSwagger UIを使ってビジュアルに見てみます。

no title

Swagger UIを使う方法はいくつかあるみたいですが、今回はWildFly Swarmに組み込まれたものを使いました。

Swagger UI Server

ダウンロードして、起動。ポートは9000としました。

$ wget http://repo2.maven.org/maven2/org/wildfly/swarm/servers/swagger-ui/2017.2.0/swagger-ui-2017.2.0-swarm.jar
$ java -Dswarm.http.port=9000 -jar swagger-ui-2017.2.0-swarm.jar

この状態で、http://localhost:9000/swagger-ui/にアクセスすると、Swagger UIの画面を見ることができます。

f:id:Kazuhira:20170211212157p:image

ここで、画面上部のテキストフィールドに先ほど生成したSwagger SpecのURL(http://localhost:8080/api/swagger.json)を指定すれば
いいのですが、そのままだと動きません。

f:id:Kazuhira:20170211212529p:image

「CORSの設定してないんじゃない?」って言われているので、設定しましょう。

以下のサンプルにもあったCORSの設定を、ほぼそのまま流用。

no title

こうなりました。
src/main/scala/org/littlewings/javaee7/rest/CorsFilter.scala

package org.littlewings.javaee7.rest

import javax.servlet._
import javax.servlet.http.HttpServletResponse

class CorsFilter extends Filter {
  override def init(filterConfig: FilterConfig): Unit = ()

  override def doFilter(request: ServletRequest, response: ServletResponse, chain: FilterChain): Unit = {
    val res = response.asInstanceOf[HttpServletResponse]
    res.addHeader("Access-Control-Allow-Origin", "*")
    res.addHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, PUT")
    res.addHeader("Access-Control-Allow-Headers", "Content-Type")
    chain.doFilter(request, response)
  }

  override def destroy(): Unit = ()
}

UndertowのDeploymentInfoを設定するところで、ServletFilterとして追加します。

    val server = new UndertowJaxrsServer
    val deployment = server.undertowDeployment(classOf[JaxrsActivator])
    deployment.setContextPath("")
    deployment.setDeploymentName("resteasy-swagger")
    deployment.addFilter(Servlets.filter("corsFilter", classOf[CorsFilter]))
    deployment.addFilterUrlMapping("corsFilter", "/*", DispatcherType.REQUEST)
    server.deploy(deployment)

これで気を取り直して確認すると、アクセスできることが確認できます。

f:id:Kazuhira:20170211212919p:image

もう少し確認

表示されているAPIのURLを展開して、もうちょっと確認してみましょう。

例えば、「GET /calc/add」を展開するとこんな表示になるので、テキストフィールドに値を入れて
「Try it out!」を押すと結果の確認(=APIの呼び出し)もできます。

f:id:Kazuhira:20170211213222p:image

また、JSONを扱うようなAPIの場合、ちゃんとアノテーションで設定を入れていると「Model Schema」に構造が表示されるので、
これをクリックすることで入力パラメーターのテンプレートとしても使うことができます。

今回は、これを編集してデータ登録をしてみました。
f:id:Kazuhira:20170211213835p:image

まとめ

SwaggerのJAX-RS用のモジュールを使ってSwagger Specを生成し、Swagger UIで見るということをRESTEasy+Undertowで
行ってみました。

正直、Undertowでてこずるところが多く素直にJava EEサーバーにデプロイしていればもっと簡単だったのかな?と思うところも
ありますが、とりあえず目標達成、と。

今回作成したソースコードは、こちらに置いています。
https://github.com/kazuhira-r/javaee7-scala-examples/tree/master/resteasy-swagger

スパム対策のためのダミーです。もし見えても何も入力しないでください
ゲスト


画像認証

トラックバック - http://d.hatena.ne.jp/Kazuhira/20170211/1486816968