kagamihogeの日記

kagamihogeの日記です。

Spring for GraphQLのチュートリアルレベルをやる

Spring for GraphQLチュートリアルレベルの事をやる。

やったこと

hello world相当

https://start.spring.io/ でgraphql, webの依存性を追加する。build.gradleは下記となる。

plugins {
    id 'org.springframework.boot' version '2.7.0'
    id 'io.spring.dependency-management' version '1.0.11.RELEASE'
    id 'java'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-graphql'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework:spring-webflux'
    testImplementation 'org.springframework.graphql:spring-graphql-test'
}

tasks.named('test') {
    useJUnitPlatform()
}

src/main/resources/application.propertiesに以下を任意で追加する。GraphQLはクライアントが必要だがこのプロパティによりブラウザでGraphQLを試せる。

spring.graphql.graphiql.enabled true

GraphQLのスキーマを追加する。デフォルトではsrc/main/resources/graphql/**.graphqls.gqlsの拡張子でスキーマファイルを作るので、src/main/resources/graphql/schema.graphqlsを作成する。スキーマの中身は https://docs.spring.io/spring-boot/docs/current/reference/html/web.html#web.graphql.schema をまずはコピペする。

type Query {
    greeting(name: String! = "Spring"): String!
    project(slug: ID!): Project
}

""" A Project in the Spring portfolio """
type Project {
    """ Unique string id used in URLs """
    slug: ID!
    """ Project name """
    name: String!
    """ URL of the git repository """
    repositoryUrl: String!
    """ Current support status """
    status: ProjectStatus!
}

enum ProjectStatus {
    """ Actively supported by the Spring team """
    ACTIVE
    """ Supported by the community """
    COMMUNITY
    """ Prototype, not officially supported yet  """
    INCUBATING
    """ Project being retired, in maintenance mode """
    ATTIC
    """ End-Of-Lifed """
    EOL
}

まずはgreetingのQueryを作る。

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.graphql.data.method.annotation.Argument;
import org.springframework.graphql.data.method.annotation.QueryMapping;
import org.springframework.stereotype.Controller;

@Controller
@SpringBootApplication
public class BootGraphqlApplication {
  public static void main(String[] args) {
    SpringApplication.run(BootGraphqlApplication.class, args);
  }

  @QueryMapping
  public String greeting(@Argument String name) {
    return "Hello, " + name + "!";
  }
}

これを起動して http://localhost:8080/graphiql にアクセスする。下記のようなqueryを書いて実行すると結果が返ってくる。

query {
  greeting(name:"aaaaaaaabbbb")
}

次にprojectのQueryを作る。ProjectProjectStatusを作り、

public record Project(String slug, String name, String repositoryUrl, ProjectStatus status) {
}
public enum ProjectStatus {
  ACTIVE, COMMUNITY, INCUBATING, ATTIC, EOL
}

projectのQueryの本体はこんな感じ。

  @QueryMapping
  public Project project(@Argument String slug) {
    return new Project(slug, "asdf", "sadf", ProjectStatus.ACTIVE);
  }

http://localhost:8080/graphiql にアクセスしてqueryを実行すると以下のようになる。name, statusのみ指定してるのでそのフィールドしか返ってきてないのが分かる。

query {
  greeting(name:"testeset")
  
  project(slug:"hatenatena") {
    name,
    status
  }
}

Spring MVC以外で公開

上記はSpring MVCを使用したが、GraqhQLはそれらとは独立している。https://docs.spring.io/spring-boot/docs/current/reference/html/web.html#web.graphql.schema によると、下記が挙げられている。

  • spring-boot-starter-web
  • spring-boot-starter-websocket
  • spring-boot-starter-webflux
  • spring-boot-starter-rsocket

webfluxを試す。依存性にgraphql, webfluxを追加したものに変更する。

dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-graphql'
  implementation 'org.springframework.boot:spring-boot-starter-webflux'
  developmentOnly 'org.springframework.boot:spring-boot-devtools'
  testImplementation 'org.springframework.boot:spring-boot-starter-test'
  testImplementation 'io.projectreactor:reactor-test'
  testImplementation 'org.springframework.graphql:spring-graphql-test'
}

とりあえずMonoを返すようにgreetingを修正する。

  @QueryMapping
  public Mono<String> greeting(@Argument String name) {
    return Mono.fromCallable(() -> "Hello, " + name + "!");
  }

あとは特に変わらず http://localhost:8080/graphiql からアクセスできる。

PostmanをGraqhQLクライアントとして使用

PostmanをGraqhQLクライアントとして使用する。

GraphQLのエンドポイントに POST http://localhost:8080/graphql注意 graphiqlではない)にアクセスする。BodyにGraphQLを選択してRefreshアイコンをクリックすれば後の使い勝手はgraphiqlと似たような感じ。

参照ドキュメント

WebTestClientによるintegration testの記述

spring-bootで作成するweb-apiのintegration test(以下IT)を記述するのにWebTestClientが使える。これは内部的にWebClientを使用する。同様にTestRestTemplateは内部的にRestTemplateを使用する。環境に応じて使い分けになる。今回はWebTestClientによるITの記述について。

ドキュメントは WebTestClient :: Spring Docsにあり、ここ読めばだいたいは困らないと思う。

ソースコード

build.gradle

plugins {
  id 'org.springframework.boot' version '2.6.7'
  id 'io.spring.dependency-management' version '1.0.11.RELEASE'
  id 'java'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'

configurations {
  compileOnly {
    extendsFrom annotationProcessor
  }
}

repositories {
  mavenCentral()
}

dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-web'
  compileOnly 'org.projectlombok:lombok'
  developmentOnly 'org.springframework.boot:spring-boot-devtools'
  annotationProcessor 'org.projectlombok:lombok'
  testImplementation 'org.springframework.boot:spring-boot-starter-test'
  testImplementation 'org.springframework.boot:spring-boot-starter-webflux'
}

tasks.named('test') {
  useJUnitPlatform()
}

注意点として、WebTestClientのinterfaceはspring-testにいるがWebClientの実クラスがないと実行時エラーになる。ので、依存性にwebfluxが必要になる。webfluxの依存性はtestImplementationにしているがこれは従来のwebでのITを想定してるため。webfluxを使うならtestにする必要はない。

controller

まず適当なcontrollerを作る。

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@SpringBootApplication()
public class SampleWebTestClientApplication {
  public record SampleResponse(String first, String second, List<Integer> list) {
  }

  @RequestMapping("/sample")
  public SampleResponse sample() {
    return new SampleResponse("f", "s", List.of(1, 2, 3));
  }

  public static void main(String[] args) {
    SpringApplication.run(SampleWebTestClientApplication.class, args);
  }
}

test

テストクラスを作る。

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.test.web.reactive.server.WebTestClient;

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
public class ApplicationTest {
  @LocalServerPort
  int port;
  
  @Autowired
  WebTestClient client;
  
  @Test
  void test() {
    String json = """
        {
          "second":"s",
          "first":"f"  ,
          
          "list":[3,2,1]
        }
        """;
    client.get().uri("/sample")
      .exchange()
      .expectStatus().isOk()
      .expectBody().json(json);
    }
}
  • @SpringBootTestは今まで通りのapiを実際に起動してのテストを行うためのアノテーション
  • WebTestClient@SpringBootTestによって自動的にbeanが作られる。どうゆうbeanが作られるかはここを参照。色々設定してなければ、 WebTestClient.bindToServer().baseUrl("http://localhost:" + port).build()とおおむね同様と見てよい。
  • apiにアクセスしてレスポンスのjson文字列同士を比較している。javaのクラスにdeserializeしてから比較しても良いと思う。ただ、デフォルトで割と柔軟なjsonチェックをしてくれるので、ITとしてはこれで良いんじゃないかな、とか思う。
  • 柔軟なチェックの概要はソースコードにもある通り、空白や改行・要素順序・配列の要素順序、を無視してくれる。また、expect側に要素が欠けててもokになる(ex {"second":"s"}でもok)。strictなチェックをしたい場合は.expectBody().json(json, true)にする*1
  • WebTestClientに色々あるassertでは不足していてカスタマイズしたい場合はconsumeWithを使う。以下はjson(expect)とほぼ同等の挙動になる。
    client.get().uri("/sample")
      .exchange()
      .expectStatus().isOk()
      .expectBody(String.class)
      .consumeWith(result -> {
        try {
          new JsonExpectationsHelper().assertJsonEqual(json, result.getResponseBody(), false);
        } catch (Exception e) {
          e.printStackTrace();
        }
      });

*1:5.3.16以降で指定可能

spring-boot + MockServer

spring-bootのweb-apiのintegration testでMockServerを使用する。

ソースコード

build.gradle

springとの連携用のmockserver-spring-test-listener-no-dependenciesの依存性を追加する。

plugins {
  id 'org.springframework.boot' version '2.6.6'
  id 'io.spring.dependency-management' version '1.0.11.RELEASE'
  id 'java'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

configurations {
  compileOnly {
    extendsFrom annotationProcessor
  }
}

repositories {
  mavenCentral()
}

dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-web'
  compileOnly 'org.projectlombok:lombok'
  developmentOnly 'org.springframework.boot:spring-boot-devtools'
  annotationProcessor 'org.projectlombok:lombok'
  testImplementation 'org.springframework.boot:spring-boot-starter-test'

  testImplementation 'org.mock-server:mockserver-spring-test-listener-no-dependencies:5.13.2'
  
}

tasks.named('test') {
  useJUnitPlatform()
}

外部アクセスを伴う適当なrest-apiを作成する。

import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;

@RestController
@SpringBootApplication()
public class SampleWireMockApplication {
  @Value("${sample.remote.path}")
  String path;

  @RequestMapping("/sample/{key}")
  public String sample(@PathVariable String key) {
    RestTemplate template = new RestTemplate();

    String result = template.getForObject(path + "/sample-api/" + key, String.class);
    return result;
  }

  public static void main(String[] args) {
    SpringApplication.run(SampleWireMockApplication.class, args);
  }
}

プロパティファイルに外部アクセス先URLを持つ。

server.port=8081

sample.remote.path=http://example.com:12345

テストコードを作成する。

import static org.junit.jupiter.api.Assertions.assertEquals;

import java.util.Map;

import org.junit.jupiter.api.Test;
import org.mockserver.client.MockServerClient;
import org.mockserver.model.Header;
import org.mockserver.model.HttpRequest;
import org.mockserver.model.HttpResponse;
import org.mockserver.springtest.MockServerTest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.web.server.LocalServerPort;

@SpringBootTest(properties = { "sample.remote.path=${server.url}" }, webEnvironment = WebEnvironment.RANDOM_PORT)
@MockServerTest("server.url=http://localhost:${mockServerPort}")
public class ApplicationTest {
  @LocalServerPort
  int serverPort;

  @Autowired
  TestRestTemplate template;

  MockServerClient mockServerClient;

  @Test
  void test() {
    mockServerClient
      .when(HttpRequest
          .request()
          .withMethod("GET")
          .withPath("/sample-api/key123"))
      .respond(HttpResponse
          .response()
          .withStatusCode(200)
          .withHeader(
              new Header("Content-Type", "application/json; charset=utf-8"))
          .withBody("""
                  {
                    "sample-id": "id12345",
                    "sample-value": "value"
                  }
                  """));

    Map<String, String> actual = template.getForObject("http://localhost:" + serverPort + "/sample/key123", Map.class);
    Map<String, String> expected = Map.of("sample-id", "id12345", "sample-value", "value");

    assertEquals(expected, actual);
  }
}
  • webEnvironment = WebEnvironment.RANDOM_PORTでランダムポートでこのapiが起動する。
  • @MockServerTestでspring連携が有効になりMockServerClientフィールドを宣言すれば使用可能になる。
  • @MockServerTestserver.url=http://localhost:${mockServerPort}でmock-serverのURLを指定できる。
  • @SpringBootTestpropertiesで外部アクセスURLのプロパティをmock-serverのURLで上書きする。
  • mockServerClient.when...でモックの定義をする。
  • TestRestTemplateRestTemplateでも構わない。これからはWebTestClientが主流になるかも?
  • assertEqualsはサンプルコードなので簡易にMapでの検証にした。実際にはrecordとかデータ保持クラスに詰め替えたりしてから検証、になると思われる。

関連