🏗️

connect-web x connect-query x mswを使った開発環境構築

2023/08/24に公開

Connectを利用する機会があったので、使い方を共有します。

内容

Open API Schemaを利用したスキーマ駆動開発をする際、storybook x msw x tanstack-queryを採用することが多いです。
この記事ではこの構成と同等の環境を整える方法を共有します。

1. Protocol Buffer Schemaを元に型定義等を生成

package追加

# plugin
yarn add --dev @bufbuild/protoc-gen-connect-web @bufbuild/protoc-gen-es @bufbuild/protoc-gen-connect-query
# runtime
yarn add @bufbuild/connect-web @bufbuild/protobuf @bufbuild/connect-query

codegen設定ファイルを作成

# buf.gen.yaml
version: v1
plugins:
  - name: es
    out: src/gen
    opt:
      - target=ts
      - import_extension=none
  - name: connect-web
    out: src/gen
    opt:
      - target=ts
      - import_extension=none
  - name: connect-query
    out: src/gen
    opt:
      - target=ts
      - import_extension=none

schemaを用意

syntax = "proto3";

package example.greet.v1;

message GreetRequest {
  string name = 1;
}

message GreetResponse {
  string greeting = 1;
}

service GreetService {
  rpc Greet(GreetRequest) returns (GreetResponse) {}
}

codegen

buf generate --template buf.gen.yaml proto_dir

2. mock data factory作成

import type { PlainMessage } from '@bufbuild/protobuf';

import { GreetResponse } from '@/gen/example/greet/v1/greet_pb';

export const mockGreetResponse = {
  base: (
    override?: Partial<PlainMessage<GreetResponse>>
  ): PlainMessage<GreetResponse> => ({
    greeting: 'hello world!',
    ...override,
  }),
};

3. msw handler作成

import { rest } from 'msw';

import { GreetService } from '@/gen/example/greet/v1/greet_connectweb';
import { delayedResponse, path } from '@/libs/msw/util';

import { mockGreetResponse } from '../data';

const API_ENDPOINT = {
  greet: path({
    path: `/${GreetService.typeName}/${GreetService.methods.greet.name}`,
  }),
} as const;

export const mockGreetHandler = {
  greet: {
    success: () =>
      rest.post(API_ENDPOINT.greet, (_req, _res, ctx) => {
        return delayedResponse({})(
          ctx.status(200),
          ctx.json(mockGreetResponse.base())
        );
      }),
    error: {
      unauthorized: () =>
        rest.post(API_ENDPOINT.greet, (_req, _res, ctx) => {
          return delayedResponse({})(ctx.status(400));
        }),
    },
  },
};
delayedResponse, pathの実装
import { createResponseComposition, context } from 'msw';

const isTesting = process.env.NEXT_IS_TEST === 'true';

export const delayedResponse = ({
  millisecond = 1000,
}: {
  millisecond?: number;
}) =>
  createResponseComposition(undefined, [
    context.delay(isTesting ? 0 : millisecond),
  ]);

export const delayedResponseOnce = createResponseComposition({ once: true }, [
  context.delay(isTesting ? 0 : 1000),
]);

const CLIENT_PATH: Record<'default', string> = {
  default: process.env.NEXT_PUBLIC_API_BASE_URL || '',
};

export const path = ({
  path,
  client = 'default',
}: {
  path: string;
  client?: 'default';
}) => {
  return `${CLIENT_PATH[client]}${path}`;
};

4. client作成

import { createConnectTransport } from '@bufbuild/connect-web';

import { authInterceptor } from './interceptors';

export const connectTransport = createConnectTransport({
  baseUrl: process.env.NEXT_PUBLIC_API_BASE_URL || '',
  interceptors: [authInterceptor],
});
interceptor例

Authorization Headerにaccess token付与

import { Interceptor } from '@bufbuild/connect';

export const authInterceptor: Interceptor = (next) => async (req) => {
  const accessToken = getAccessToken();
  if (accessToken != null) {
    req.header.set('Authorization', `Bearer ${accessToken}`);
  }
  return await next(req);
};

5. 呼び出し(tanstack-query)

tanstack-queryのsetupは省略します。

import { useQuery } from '@tanstack/react-query';

import { greet } from '@/gen/example/greet/v1/greet-GreetService_connectquery';

export const HogeComponent = () => {
  const {data} = useQuery(greet.useQuery({ name: 'hoge' }))
  console.log(data)
  
  return (
    <div>hoge</div>
  )

6. storybook

storybookやmswのsetupは省略します。

import type { Meta, StoryObj } from '@storybook/react';

import { mockGreetHandler } from '@/mocks/server/handlers';

import { HogeComponent } from './HogeComponent';

const meta: Meta<typeof HogeComponent> = {
  component: HogeComponent,
  parameters: {
    controls: { expanded: true },
  },
};
export default meta;

export const Base: StoryObj<typeof HogeComponent> = {
  args: {},
  parameters: {
    msw: {
      handlers: [mockGreetHandler.greet.success()],
    },
  },
};

おわりに

エコシステムが整っていたので、意外とすんなりいつもの開発パターンに持ち込めてgRPC/Connectにとても良い印象を持ちました

Discussion