以下の記事の続きです。
目的
GET以外のAPI、例外ハンドリング、XMLでの応答が出来るようにします。
もうあんまりIntelliJ関係ないですけど。
各APIでセットしているステータスコードは、以下のサイトにある推奨のものにしています。
GOAL
- POST/PUT(UPDATE)/DELETEが出来る
- 例外ハンドリングが出来る
- 500エラーではなくて404(Not Found)や309(Conflict)を返すようにする
- XMLで応答が出来る
POSTメソッド
1.リソースクラスにAPIメソッドを追加
GET
メソッドが@GET
だったのだから、当然、POST
メソッドは@POST
を付けます。
@Context
UriInfo uriInfo;
@POST
@Consumes({MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML})
public Response addEmployee(Employee employee) {
EmployeeRepository.getInstance().insert(employee.getId(), employee.getFirstName());
UriBuilder builder = uriInfo.getAbsolutePathBuilder();
builder.path(String.valueOf(employee.getId()));
return Response.created(builder.build()).build();
}
@Context
で、実行環境に関する情報(コンテキスト)を取得できます。
ここでは、(参考にしたページのサンプルのままなのですが)ヘッダーに、追加したデータのアクセスURLを返してあげています。そのため、UriInfo
が必要なので使っているようです。
2.動作確認
ここからはPostmanで確認していきます。
- メソッドタイプを[POST]にする
- [BODY]タブを選ぶ
- [raw]を選ぶ
- ドロップダウンから、[JSON]を選ぶ
- 下記の文字列を設定する
{"firstName":"Honeycomb","id":11}
実行してみてください。
Statusが201 Created
になっていれば成功です。
また、[Headers]タブをクリックすると、レスポンスヘッダーが見られます。この中に、Location
という項目があり、Urlが入っているはずです。
Urlをコピペしてブラウザに貼り付ければ、新しいデータにアクセスできることが分かります。
3.余談
お急ぎの方は飛ばしてドウゾ。
Location
を返している部分ですが、参考にしたサイトは、String.format
を使って次のようにしていました。
String.format("%s/%s",uriInfo.getAbsolutePath().toString()
UnitTestは、これでlocalhost:xxxx/employees/11
みたいに返ってくるのですが、ところが、これをPostmanやターミナルからcurl
コマンドで実行すると、localhost:xxxx/empoloyees//11
みたいに最後のパスセパレータ(/)がなぜか重複してしまい、当然そのままURLをコピペしたのではアクセス出来ません。
URLEncodeがらみと思って調査&試しましたが、どうやら違うようでした。
色々試行錯誤して、結局、上記の通り、UriBuilder
を使うことで解決しました。
というお話でした(笑)
あとは、これまではブラウザアクセスしかしていなくて、「POSTするにはどうするんだ?クライアントアプリ作らなきゃならんの?」と思っていました。絶対ツールがあるはずだと思って探し、Postmanを知ったのは実はこの時でした(笑)
PUT(UPDATE)メソッド
1.リソースクラスにAPIメソッドを追加
GET
メソッドが~~略
@PUT
@Consumes({MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML})
public Response updateEmployee(Employee employee) {
EmployeeRepository.getInstance().update(employee.getId(), employee.getFirstName());
// 新規作成した場合はcreatedを返す必要があるが、このサンプルではエラーとするため、常にokを返す
return Response.ok().build();
}
2.動作確認
- Postmanで、メソッドタイプを[PUT]を選んで、jsonを入力
{"firstName":"Frozen yogurt","id":8}
Statusは200になればOKです。
続いて/allなどを叩いてみれば、該当のデータが変更されているのがわかります。
DELETEメソッド
1.リソースクラスにAPIメソッドを追加
GET
メソッドが~~略
@DELETE
@Path("/{id}")
@Consumes({MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML})
public Response deleteEmployee(@PathParam("id") int id) {
EmployeeRepository.getInstance().delete(id);
// Entityの状態を返す場合はokを返す。
// 受け付けたが処理が終わっていない場合は(キューに乗っただけなど)acceptedを返す
// このサンプルでは削除が完了して該当コンテントがなくなったことだけ返す
return Response.noContent().build();
}
2.動作確認
- Postmanで、メソッドタイプを[DELETE]を選んで、パスを入力
Statusは204になればOKです。
続けて/allを叩いてみると、id=3が無くなっているはずです。
例外ハンドリング
さて、例外クラスをたくさん作ってきましたが、全く使われていません。いや、使われてはいるのですが、例外が起こると、500(Internal Server Error)が起きてしまいます。これだと、サーバー側でクラッシュしてしまっているかのようです。そうではなくて、単にデータが無いだけなので、404(Not Found)を返すようにしたいです。
これには、ExceptionMapper
というのを使います。
次のようなクラスを作ります。
@Provider
public class NotFoundExceptionHandler implements ExceptionMapper<EmployeeNotFoundException> {
public Response toResponse(EmployeeNotFoundException ex) {
return Response.status(Response.Status.NOT_FOUND).build();
}
}
これは、EmployeeNotFoundException
が投げられたのを検知したら、ステータスコード404を返す、というハンドラーになります。
これで実行し、存在しないパスにアクセスしてみてください。
例えば、/employee/1
ですね。
404が表示されましたか?以前は、500エラーが返っていたはずです。
これを、例外クラスごとに量産します。
(ああ、kotlinなら同じファイルに全部書けるのに・・・ファイルが無駄に増えなくていいのに・・・)
EmployeeNameNotFoundException
の場合も、404エラーでいいでしょう。
DuplicateIdException
の時は、409
(conflict)を返しましょう。
@Provider
public class DuplicateExceptionHandler implements ExceptionMapper<DuplicateIdException> {
public Response toResponse(DuplicateIdException ex) {
return Response.status(Response.Status.CONFLICT).build();
}
}
存在するidに対して、POSTをしてみてください。
Statusが409になっていれば正常です。
XMLでの応答
1. どうでもいい愚痴
お急ぎの方は2へドウゾ(笑)
これがむちゃくちゃハマりました。
たぶん、JDKを8以下で実行してきていたら、ハマらなかったことでしょう。
大人の事情で11を使わないといけないので、そこでハマってしまっていました。
Jersey公式のサンプルや、他の解説ページをいくつも見ても、**「XMLで応答を返すには、モデルクラス(Entity)に、@XmlRootElement
って書いてAccept
にapplication/xml
を指定すればいいんだよ」**という情報しかなくて、でもそれだけだと、「500 Internal Server Error」になってしまい、何時間もさまよいました(T_T)
最終的に、こちらのページにたどり着いて、ようやく原因が分かりました。
- Java 9以降でJAXBを使用するには、外部JARが必要
https://github.com/acroquest/javabook-support/issues/49
JDKのバージョン別に何が変わったのか、そういえば調べていたのに、全く気づきませんでした(トホホ)
資料に書いたら忘れちゃうの、ダメですねえ。。。
ということで、JAXB関連の依存関係を追加し、モデルクラスに@XmlRootElement
を付けるだけです。
2. 依存関係の追加
pom.xml
の<dependencies>
下に次のように追加します。
<!-- https://mvnrepository.com/artifact/javax.xml.bind/jaxb-api -->
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>2.3.1</version>
</dependency>
<!-- https://mvnrepository.com/artifact/javax.activation/activation -->
<dependency>
<groupId>javax.activation</groupId>
<artifactId>activation</artifactId>
<version>1.1.1</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.glassfish.jaxb/jaxb-runtime -->
<dependency>
<groupId>org.glassfish.jaxb</groupId>
<artifactId>jaxb-runtime</artifactId>
<version>2.3.2</version>
</dependency>
3. XMLエレメントを示すアノテーションの追加
Employee
クラスにアノテーションを付けます。
@XmlRootElement
public class Employee {
...
これだけです。
4. 動作確認
PostmanでAccept
タイプを指定して試してみましょう。
(1)application/json
を指定
(2)application/xml
を指定
なお、ブラウザ(Chrome)だと、xmlがデフォルトで返ってくるようですが、PostmanはAccept
タイプを指定しない場合はjsonで返ってくるようです。
他のメソッドについても試してみてください。
JUnitテスト
ここまでのJUnitテストも書いておきます。
それと、せっかくJUnit5を使っているので、GET
のテストをパラメタライズテストにしてみようと思います。
さらに、xmlも受け取れるようになったので、そのテストを追加します。
1. パラメタライズテスト
パラメタライズテストとは、パラメータの配列を渡して、パラメータ違いのテストを繰り返し行える機能のことを言います。
書き方は簡単。
-
@Test
から@ParameterizedTest
にアノテーションを変える -
@ValueSource
でパラメータの配列を渡す
簡単ですね。もう少し色々複雑なこともできるのですが、とりあえずこれができれば十分かと思います。
@ParameterizedTest
@ValueSource(ints = {3, 4, 5, 8, 9})
public void getEmployee(int id) {
String urlPath = String.format("/employees/%d", id);
Employee employee = target(urlPath).request().get(Employee.class);
Employee expect = EmployeeRepository.getInstance().select(id);
assertThat(employee).isEqualToComparingFieldByField(expect);
}
これで、引数id
に{3,4,5,8,9}
という値を順番に使って繰り返しテストをしてくれます。
実行すると、こんな結果表示がされます。
リストが見えない場合は、チェックアイコンをクリックしてみてください。
繰り返しのテストを書くのが楽になりました。
2. XMLのテスト
(1)GET系
GET系は、Accept
別にちゃんと通過するかテストを追加します。これもまた、パラメタライズテストで出来ます。
@ParameterizedTest
@ValueSource(strings = {MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML})
public void getAll(String mediaType) {
final Response response = target("/employees/all").request().accept(mediaType).get();
assertThat(response.getHeaderString("Content-Type"))
.isEqualTo(mediaType);
...
}
そうしたら、getEmployee
のテストも、jsonの時とxmlの時で分けたいですね。
すると、複数のテストパラメーターが必要になってきます。
こういう時には、MethodSource
を使ってパラメータを作って渡します。
@ParameterizedTest
@MethodSource("getParamProvider")
public void getEmployee(int id, String mediaType) {
String urlPath = String.format("/employees/%d", id);
final Response response = target(urlPath).request().accept(mediaType).get();
assertThat(response.getHeaderString("Content-Type"))
.isEqualTo(mediaType);
Employee employee = response.readEntity(Employee.class);
Employee expect = EmployeeRepository.getInstance().select(id);
assertThat(employee).isEqualToComparingFieldByField(expect);
}
static Stream<Arguments> getParamProvider() {
return Stream.of(
Arguments.of(3, MediaType.APPLICATION_JSON),
Arguments.of(4, MediaType.APPLICATION_JSON),
Arguments.of(5, MediaType.APPLICATION_JSON),
Arguments.of(8, MediaType.APPLICATION_JSON),
Arguments.of(9, MediaType.APPLICATION_JSON),
Arguments.of(3, MediaType.APPLICATION_XML),
Arguments.of(4, MediaType.APPLICATION_XML),
Arguments.of(5, MediaType.APPLICATION_XML),
Arguments.of(8, MediaType.APPLICATION_XML),
Arguments.of(9, MediaType.APPLICATION_XML)
);
}
まあこんなに必要はないですが、MethodSource
のサンプルということで。
(2)POST
POSTのテストも、jsonをPOSTした場合とxmlをPOSTした場合のテストが必要ですが、これもパラメタライズで出来てしまいます。
ただ、気をつけないといけないのは、セッションが切れていないようなので、staticなEmployeeRepository
には追加されたデータが残ります。重複追加はエラーになる仕様にしているので、同じ物を追加はできないということに注意して、プロバイダーを作る必要があります。
@ParameterizedTest
@MethodSource("postRawProvider")
public void addEmployee(int id, String bodyRaw, String mediaType) {
final Response response = target("/employees").request()
.post(Entity.entity(bodyRaw, mediaType));
assertThat(response.getStatus()).isEqualTo(201);
assertThat(response.getHeaderString("Location"))
.isEqualTo("http://localhost:9998/employees/" + id);
}
static Stream<Arguments> postRawProvider() {
final String json = "{\"firstName\":\"Honeycomb\",\"id\":11}";
final String xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" +
"<employee><firstName>KitKat</firstName><id>19</id></employee>";
return Stream.of(
Arguments.of(11, json, MediaType.APPLICATION_JSON),
Arguments.of(19, xml, MediaType.APPLICATION_XML)
);
}
追加するid,jsonまたはxmlの文字列,MediaTypeを引数で渡しています。
3. PUT/DELETEのテスト
PUT, DELETEのテストを追加します。PUTはjsonとxmlの両方をテストします。
該当箇所は全部でこうなります。
@ParameterizedTest
@MethodSource("putRawProvider")
public void updateEmployee(int id, String bodyRaw, String mediaType) {
final Response response = target("/employees").request()
.put(Entity.entity(bodyRaw, mediaType));
assertThat(response.getStatus()).isEqualTo(200);
Employee employee = target("/employees/"+id).request().get(Employee.class);
Employee expected = EmployeeRepository.getInstance().select(id);
assertThat(employee).isEqualToComparingFieldByField(expected);
}
static Stream<Arguments> putRawProvider() {
final String json = "{\"firstName\":\"Frozen yogurt\",\"id\":8}";
final String xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" +
"<employee><firstName>Cup Cake</firstName><id>3</id></employee>";
return Stream.of(
Arguments.of(8, json, MediaType.APPLICATION_JSON),
Arguments.of(3, xml, MediaType.APPLICATION_XML)
);
}
@Test
public void deleteEmployee() {
final Response response = target("/employees/9")
.request().delete();
assertThat(response.getStatus()).isEqualTo(204);
}
3. 例外系
例外をハンドリング出来るようになったので、これもテストしましょう。
(1)コンフィグを追加
何もしないと、ExceptionMapperはテスト中には実行されません。
次のようにテストのコンフィグに追加します。
@Override
protected Application configure() {
return new ResourceConfig(EmployeeResource.class)
// 以下を追加
.register(DuplicateExceptionHandler.class)
.register(NameNotFoundExceptionHandler.class)
.register(NotFoundExceptionHandler.class);
}
(2)例外系テストメソッドの作成
- selectに存在しないidを指定した時
- searchに存在しない文字列を指定した時
- postに既存のidを指定した時
- putに存在しないidを指定した時
- deleteに存在しないidを指定した時
これくらいでしょうか。
該当テストコードは以下のようになります。
@Test
public void exception_selectEmployee(){
final Response response = target("/employees/1").request().get();
assertThat(response.getStatus()).isEqualTo(404);
}
@Test
public void exception_searchEmployee(){
final Response response = target("/employees/search?name=android").request().get();
assertThat(response.getStatus()).isEqualTo(404);
}
@ParameterizedTest
@MethodSource("putRawProvider")
public void exception_addEmployee(int id, String bodyRaw, String mediaType) {
final Response response = target("/employees").request()
.post(Entity.entity(bodyRaw, mediaType));
assertThat(response.getStatus()).isEqualTo(409);
}
@ParameterizedTest
@MethodSource("putExceptionProvider")
public void exception_updateEmployee(int id, String bodyRaw, String mediaType) {
final Response response = target("/employees").request()
.put(Entity.entity(bodyRaw, mediaType));
assertThat(response.getStatus()).isEqualTo(404);
}
static Stream<Arguments> putExceptionProvider() {
final String json = "{\"firstName\":\"Lollipop\",\"id\":21}";
final String xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" +
"<employee><firstName>Jelly Bean</firstName><id>17</id></employee>";
return Stream.of(
Arguments.of(21, json, MediaType.APPLICATION_JSON),
Arguments.of(3, xml, MediaType.APPLICATION_XML)
);
}
@Test
public void exception_deleteEmployee(){
final Response response = target("/employees/1").request().get();
assertThat(response.getStatus()).isEqualTo(404);
}
putRawProvider
(PUTテスト用)をちょうどいいので使い回しています。
ただし使っているのはexception_addEmployee
(POSTの例外テスト)です。
感想
なかなかハマりどころの多い箇所でした。わかってしまえば単純なことだったりするのですが・・・
ここまでのコードを、Githubにアップしました。
https://github.com/le-kamba/JerseySample
次はAPI部分をモジュール分割してみます。
こんな形が理想ですね。
jerseysample
|- pom.xml
|- src/main/java/package/xxx/sampleapp
|- serverapi
| |- pom.xml
| |- src/main/java/package/yyy/server
|- repository
|- pom.xml
|- src/main/java/package/zzz/repository
依存関係の書き方や実行方法の実験を兼ねているため、リポジトリ層も分けてみます。
参考
- JerseyとSpringでのREST API
https://www.codeflow.site/ja/article/jersey-rest-api-with-spring - Jax-RS Response.created(location) for routes with path parameters
https://stackoverflow.com/questions/52773898/jax-rs-response-createdlocation-for-routes-with-path-parameters - JAX-RSでJSONやXMLをPOSTしてみる
https://shinsan.hatenablog.jp/entry/20080907/p1 - JUnit 5 のパラメーター化テストは超便利
https://qiita.com/oohira/items/5030182af29a30166868 - HTTP Methods
https://restfulapi.net/http-methods/ - ExceptionMapper toResponse not Invoking while running junit test case. but is invoking when running through Client/Postman
https://stackoverflow.com/questions/45466435/exceptionmapper-toresponse-not-invoking-while-running-junit-test-case-but-is-in