tRPC より良いかもしれない "ts-rest" の紹介(ユースケースによる)
ts-rest とは
ts-rest は、TypeScript 製 REST Server と TypeScript 製 REST Client をタイプセーフに通信できるようにするアダプタ?です。
OpenAPI Generator のようなソースコード生成をする事なくタイプセーフさを実現しています。
似たようなものに tRPC や Hono RPC があります。
tRPC や Hono RPC はサーバの実装コードの型を利用しているのに対し、ts-rest は contract(インターフェース) を定義し、サーバは contract を元にコードを実装、クライアントは contract を利用したリクエストを実行するような仕組みになっています。
ts-rest は、contract を npm パッケージ等を通じて配布すことができると思います。
tRPC や Hono RPC は、フロントエンドと BFF を繋ぐのに向いていて ts-rest は純粋に REST Server と REST Client を繋ぐのに向いているのではないかと考えています。
公式トップページのコーディング動画を観て頂くとイメージが付くと思います。
ts-rest の Quickstart
そんな ts-rest ですが、2023年5月9日時点の Quickstart をクイックに動かすことは難しかったです。
Quickstart には不完全な整合性がとれていないコードが記載されています。
どこかで Quickstart のコードが管理されていないかと確認すると、本体のレポジトリ内に発見します。
そして、PostgreSQL の DB サーバが必要である事が分かりましたが、クイックにできないため SQLite に書き換えました(Express の Example は DB サーバの起動が不要でした)。
成果物
実際に動かす事ができた成果物です。
- repository root
-
packages/contract
: contract(インターフェース)を公開するためのプロジェクトです。 -
packages/server
: REST Server を起動するためのプロジェクトです。Express と NestJS が選択できるようですが、ここでは NestJS を使っています。 -
packages/test-client
: REST Client を実行するためのテスト用プロジェクトです。実際のクライアントのコードは、別レポジトリ管理される事を想定しています。
-
コード抜粋
contract
server と client を繋ぐインターフェースを定義します。
// packages/contract/contract.ts
import { initContract } from '@ts-rest/core';
import { z } from 'zod';
export interface Post {
id: string;
title: string;
description: string | null;
content: string | null;
published: boolean;
}
const PostSchema = z.object({
id: z.string(),
title: z.string(),
description: z.string().nullable(),
content: z.string().nullable(),
published: z.boolean(),
});
const c = initContract();
export const contract = c.router({
createPost: {
method: 'POST',
path: '/posts',
responses: {
201: PostSchema,
},
body: z.object({
title: z.string().transform((v) => v.trim()),
content: z.string(),
published: z.boolean().optional(),
description: z.string().optional(),
}),
summary: 'Create a post',
},
updatePost: {
method: 'PATCH',
path: `/posts/:id`,
responses: { 200: PostSchema },
body: z.object({
title: z.string().optional(),
content: z.string().optional(),
published: z.boolean().optional(),
description: z.string().optional(),
}),
summary: 'Update a post',
},
deletePost: {
method: 'DELETE',
path: `/posts/:id`,
responses: {
200: z.object({ message: z.string() }),
404: z.object({ message: z.string() }),
},
body: null,
summary: 'Delete a post',
},
getPost: {
method: 'GET',
path: `/posts/:id`,
responses: {
200: PostSchema,
404: z.null(),
},
query: null,
summary: 'Get a post by id',
},
getPosts: {
method: 'GET',
path: '/posts',
responses: {
200: z.object({
posts: PostSchema.array(),
count: z.number(),
skip: z.number(),
take: z.number(),
}),
},
query: z.object({
take: z.string().transform(Number),
skip: z.string().transform(Number),
search: z.string().optional(),
}),
summary: 'Get all posts',
},
testPathParams: {
method: 'GET',
path: '/test/:id/:name',
pathParams: z.object({
id: z.string().transform(Number),
}),
query: z.object({
field: z.string().optional(),
}),
responses: {
200: z.object({
id: z.number().lt(1000),
name: z.string(),
defaultValue: z.string().default('hello world'),
}),
},
},
});
server
contract を実装するサーバのコードです。
// packages/server/src/app.controller.ts
import { Controller, Get, Query } from '@nestjs/common';
import { contract } from '@foo-api/contract';
import {
nestControllerContract,
NestControllerInterface,
NestRequestShapes,
TsRest,
TsRestRequest,
} from '@ts-rest/nest';
import { AppService } from './app.service';
const c = nestControllerContract(contract);
type RequestShapes = NestRequestShapes<typeof c>;
// You can implement the NestControllerInterface interface to ensure type safety
@Controller()
export class AppController implements NestControllerInterface<typeof c> {
constructor(private readonly postService: AppService) {}
@Get('/test')
test(@Query() queryParams: any) {
return { queryParams };
}
@TsRest(c.getPosts)
async getPosts(
@TsRestRequest()
{ query: { take, skip, search } }: RequestShapes['getPosts']
) {
const { posts, totalPosts } = await this.postService.getPosts({
take,
skip,
search,
});
return {
status: 200 as const,
body: { posts, count: totalPosts, skip, take },
};
}
@TsRest(c.getPost)
async getPost(@TsRestRequest() { params: { id } }: RequestShapes['getPost']) {
const post = await this.postService.getPost(id);
if (!post) {
return { status: 404 as const, body: null };
}
return { status: 200 as const, body: post };
}
@TsRest(c.createPost)
async createPost(@TsRestRequest() { body }: RequestShapes['createPost']) {
const post = await this.postService.createPost({
title: body.title,
content: body.content,
published: body.published,
description: body.description,
});
return { status: 201 as const, body: post };
}
@TsRest(c.updatePost)
async updatePost(
@TsRestRequest() { params: { id }, body }: RequestShapes['updatePost']
) {
const post = await this.postService.updatePost(id, {
title: body.title,
content: body.content,
published: body.published,
description: body.description,
});
return { status: 200 as const, body: post };
}
@TsRest(c.deletePost)
async deletePost(
@TsRestRequest() { params: { id } }: RequestShapes['deletePost']
) {
await this.postService.deletePost(id);
return { status: 200 as const, body: { message: 'Post Deleted' } };
}
@TsRest(c.testPathParams)
async testPathParams(
@TsRestRequest() { params }: RequestShapes['testPathParams']
) {
return { status: 200 as const, body: params };
}
}
test-client
contract を利用するクライアントのコードです。
// packages/test-client/client.ts
import { initClient } from "@ts-rest/core";
import { contract } from "@foo-api/contract";
const client = initClient(contract, {
baseUrl: "http://localhost:3000",
baseHeaders: {},
});
client.createPost({
body: {
title: "Post Title",
content: "Post Content",
},
}).then(({ body, status }) => {
if (status === 201) {
// body is Post
console.log(body);
} else {
// body is unknown
console.log(body);
}
});
動作手順
以下、動作手順について整理します。
0. 前提
- Node.js v18 以上
1. Example レポジトリの clone
$ git clone https://github.com/chibat/ts-rest-example
$ cd ts-rest-example
$ npm install
2. contract のビルド
$ cd packages/contract
$ npm run build
$ cd -
4. REST Server の起動
$ cd packages/server
$ npm start dev
5. REST Client の実行
cd packages/test-client
$ npm run client
> @foo-api/test-client@1.0.0 client
> ts-node client.ts
{
id: 'clhl7i11u0000ud38s7nadq31',
createdAt: '2023-05-12T23:45:33.906Z',
updatedAt: '2023-05-12T23:45:33.906Z',
title: 'Post Title',
content: 'Post Content',
description: null,
published: false,
image: null
}
OpenAPI の Spec レスポンス
OpenAPI にも対応しました。
Endpint: http://localhost:3000/api-json
$ curl http://localhost:3000/api-json
{"openapi":"3.0.2","paths":{"/posts":{"post":{"summary":"Create a post","tags":[],"parameters":[],"operationId":"createPost","requestBody":{"description":"Body","content":{"application/json":{"schema":{"type":"object","properties":{"title":{"type":"string"},"content":{"type":"string"},"published":{"type":"boolean"},"description":{"type":"string"}},"required":["title","content"]}}}},"responses":{"201":{"description":"201","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"title":{"type":"string"},"description":{"type":"string","nullable":true},"content":{"type":"string","nullable":true},"published":{"type":"boolean"}},"required":["id","title","description","content","published"]}}}}}},"get":{"summary":"Get all posts","tags":[],"parameters":[{"name":"take","in":"query","required":true,"schema":{"type":"string"}},{"name":"skip","in":"query","required":true,"schema":{"type":"string"}},{"name":"search","in":"query","schema":{"type":"string"}}],"operationId":"getPosts","responses":{"200":{"description":"200","content":{"application/json":{"schema":{"type":"object","properties":{"posts":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"title":{"type":"string"},"description":{"type":"string","nullable":true},"content":{"type":"string","nullable":true},"published":{"type":"boolean"}},"required":["id","title","description","content","published"]}},"count":{"type":"number"},"skip":{"type":"number"},"take":{"type":"number"}},"required":["posts","count","skip","take"]}}}}}}},"/posts/{id}":{"patch":{"summary":"Update a post","tags":[],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"operationId":"updatePost","requestBody":{"description":"Body","content":{"application/json":{"schema":{"type":"object","properties":{"title":{"type":"string"},"content":{"type":"string"},"published":{"type":"boolean"},"description":{"type":"string"}}}}}},"responses":{"200":{"description":"200","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"title":{"type":"string"},"description":{"type":"string","nullable":true},"content":{"type":"string","nullable":true},"published":{"type":"boolean"}},"required":["id","title","description","content","published"]}}}}}},"delete":{"summary":"Delete a post","tags":[],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"operationId":"deletePost","responses":{"200":{"description":"200","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"]}}}},"404":{"description":"404","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"]}}}}}},"get":{"summary":"Get a post by id","tags":[],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"operationId":"getPost","responses":{"200":{"description":"200","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"title":{"type":"string"},"description":{"type":"string","nullable":true},"content":{"type":"string","nullable":true},"published":{"type":"boolean"}},"required":["id","title","description","content","published"]}}}},"404":{"description":"404","content":{"application/json":{"schema":{"type":"string","format":"null","nullable":true}}}}}}},"/test/{id}/{name}":{"get":{"tags":[],"parameters":[{"name":"name","in":"path","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"field","in":"query","schema":{"type":"string"}}],"operationId":"testPathParams","responses":{"200":{"description":"200","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"number","maximum":1000,"exclusiveMaximum":true},"name":{"type":"string"},"defaultValue":{"default":"hello world","type":"string"}},"required":["id","name"]}}}}}}}},"info":{"title":"Posts API","version":"1.0.0"}}
Swagger-UI
Swagger-UI にも対応しました。
URL: http://localhost:3000/api
補足
実際は、contract のプロジェクトをモジュールとして社内 npm レジストリに公開し、client で引き込むのが良いと思います。
「社内 npm レジストリなんてないよ」という場合は、contract の TypeScript コードのコピーで済ませる事もできるかと思います。。
おわりに
コードの詳細は、git レポジトリの方をご確認頂ければと思います。。
REST Client がフロンエンドになる場合は、tRPC でモノレポ構成にするの良いと思いますが、純粋に REST Server と REST Client を繋ぐ場合は、ts-rest が良いのかと思っています。
以上です。
Discussion