JAX-RSでMoshi使ったら `~but <null> is of type null` と怒られてハマった

JAX-RS (Jersey) で Moshi (moshi-kotlin) 使ってて、雰囲気でレスポンスをreturnさせたら以下のように怒られた:

java.lang.IllegalArgumentException: Expected a Class, ParameterizedType, or GenericArrayType, but <null> is of type null
    at com.squareup.moshi.Types.getRawType(Types.java:167)
    at com.squareup.moshi.ClassJsonAdapter$1.createFieldBindings(ClassJsonAdapter.java:83)
    at com.squareup.moshi.ClassJsonAdapter$1.create(ClassJsonAdapter.java:75)
    at com.squareup.moshi.Moshi.adapter(Moshi.java:100)
    at com.squareup.moshi.Moshi.adapter(Moshi.java:58)
    at com.squareup.moshi.CollectionJsonAdapter.newArrayListAdapter(CollectionJsonAdapter.java:52)
    at com.squareup.moshi.CollectionJsonAdapter$1.create(CollectionJsonAdapter.java:36)
    at com.squareup.moshi.Moshi.adapter(Moshi.java:100)
    at com.squareup.moshi.ClassJsonAdapter$1.createFieldBindings(ClassJsonAdapter.java:91)
    at com.squareup.moshi.ClassJsonAdapter$1.create(ClassJsonAdapter.java:75)
    at com.squareup.moshi.Moshi.adapter(Moshi.java:100)
    at com.squareup.moshi.Moshi.adapter(Moshi.java:58)
    at com.jakewharton.moshi.rs.MoshiMessageBodyWriter.writeTo(MoshiMessageBodyWriter.java:57)
    at org.glassfish.jersey.message.internal.WriterInterceptorExecutor$TerminalWriterInterceptor.invokeWriteTo(WriterInterceptorExecutor.java:266)

そんなnullで埋めてる値なんて無いしなぁとか思いながら昨日の深夜3時頃は 諦めてラーメン食べ行った のだけど、さっき気分でMoshiの実装読んでたら納得。勘が悪かった。

要約すると:

  • レスポンスに javax.ws.rs.core.Response を使ってた
  • List<E>のようなParameterized Typeなオブジェクトを直接entityとして食わせるとダメ

という話。
具体的にはこういうコードのことだ:

// lateinit var ebean: EbeanServer

@GET
@Path("/list")
@Produces(MediaType.APPLICATION_JSON)
fun listItems(): Response {
  val items = QMyItm(ebean) // ebean-querybean
    .deletedAt.isNull()
    .findList()
  
  if(anErrorOccured()) {
    return Response.serverError().build()
  }
  
  return Response.ok(items).build() // `IllegalArgumentException`
}

// fun anErrorOccured(): Boolean  = false

なぜ起きるか

これはJavaの仕様であるType Erasureによるもの。勘の良い人であればResponseentityAny! (Object) になっている時点で気付けたかも。
Type Erasureについては既に語られ尽くしているため詳細な解説を避けるが、今回のケースの場合、要は

  • ResponseへentityとしてmyObj: List<MyItm>が与えられる
  • Any! (Object) なので、Moshiは型情報を取ろうとする。List.classであることがわかる
  • Moshi自身の実装として、Listの場合はParameterized Typeのindex: 0を元に展開する必要がある
  • Parameterized Typeのindex: 0を取ろうとしたが、これは既にコンパイル時点で消去されている (Type Erasure)
  • type情報が不明だと続行できないので、IllegalArgumentExceptionを投げる

ということ。

とりあえずどうすればいいか

手っ取り早く解決する方法は、

  • return typeをList<E>のようにする (= Response使わない)
  • MyResponse#getItems で取れるような形でwrapする (= Parameterized Typeでないオブジェクトにする)

など。
恐らくResponseを使っている理由はstatus codeなんかを適宜変えたいからだと思うのだけど、ExceptionMapperを実装してあげて自前のThrowableを投げればレスポンスを変えられるので、前者を取ることをお勧めする。

たとえば以下のような形になる:

// lateinit var ebean: EbeanServer

@GET
@Path("/list")
@Produces(MediaType.APPLICATION_JSON)
fun listItems(): List<MyItm>? {
  val items = QMyItm(ebean) // ebean-querybean
    .deletedAt.isNull()
    .findList()
  
  if(anErrorOccured()) {
    throw MySomethingWrongException()
  }
  
  return items
}

// fun anErrorOccured(): Boolean  = false

// class MySomethingWrongException : RuntimeException()

// もしResponse<T>のような仕様だったら、こういった事は起こらなかったのかも