5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

oRPC + SveltekitでAPI定義を生成できるAPIサーバーを作ってみる

Last updated at Posted at 2025-12-05

はじめに

弊社ではRustを利用したバックエンド開発、Sveltekitを利用したフロントエンド開発を基本としていますが、
新規事業でPoCを検証する目的のプロダクトを日々開発していると、1つの言語で開発して1つのサーバーにデプロイしたい気持ちが湧いてくることがあります。

選択肢としては弊社の利用する技術スタックから、全部Rustで書くか全部Svelteという2択になりそうですが、
今回はフロントエンドとバックエンドの両方をSveltekitで開発するという選択をした際に、
将来的にRustで作り直した時に困らないことを考えて、oRPCというライブラリを使ってAPIの定義くらい作れるよ、という話を書きます。

(全部Rustで書かないのか?という声については、他の人に任せます)

※本記事は LabBaseテックカレンダー2025の5日目の記事です。

oRPCの紹介

oRPC
Typesafe APIs Made Simple 🪄
Easy to build APIs that are end-to-end type-safe and adhere to OpenAPI standards

と公式が謳っているように、OpenAPIに準拠したAPIを簡単に構築できるライブラリです。

以下のコードのようにメソッドチェーンでAPIを実装すると、
実装したコードをベースにOpenAPIのAPI定義を生成することができます。

// 
export const updatePlanet = os
  // メソッドチェーンでミドルウェアを各種追加できる
  .use(dbProvider)
  .use(requiredAuth)
  .use(rateLimit)
  .use(analytics)
  .use(sentryMonitor)
  .route({
    method: 'PUT',
    path: '/planets/{id}',
    summary: 'Update a planet',
    tags: ['Planets'],
  })
  // エラーの場合のレスポンスについても定義できる
  .errors({
    NOT_FOUND: {
      message: 'Planet not found',
      data: z.object({ id: UpdatePlanetSchema.shape.id }),
    },
  })
  // zodのスキーマを渡してリクエスト、レスポンスを定義できる
  .input(UpdatePlanetSchema)
  .output(PlanetSchema)
  // 実際のAPIのハンドラを実装する
  .handler(async ({ input, context, errors }) => {
     // do something...
  });

zodのV4に対応しているのが良い点です。

(最初に使おうとしていた別のライブラリがzodのV3までしか対応しておらず、「zodのV4に対応しないのかな」とIssueを漁った時に、「oRPCはzodのV4に対応しているよ!」というのを見てoRPCの存在を知りました)

実際にSveltekitに導入してみる

インストール

pnpm i --save @orpc/json-schema @orpc/openapi @orpc/server @orpc/zod 

zodによるスキーマの定義

以下の様にzodを使ってリクエスト、レスポンスの構造を定義します。

// src/lib/api/schema/paper.ts

import { z } from 'zod';
import { JSON_SCHEMA_REGISTRY } from '@orpc/zod/zod4';

// 一覧用のPaperのスキーマ
export const PaperSummarySchema = z.object({
	id: z.number().int().min(1),
	title: z.string(),
	summary: z.string(),
	description: z.string(),
	referenceUrl: z.url(),
	publishedOn: z.date()
});

// exampleをSchema単位で設定できます
JSON_SCHEMA_REGISTRY.add(PaperSummarySchema, {
	examples: [
		{
			id: 1,
			title: 'Small Language Models are the Future of Agentic AI',
			summary: 'エージェントAIの将来が小規模言語モデル(SLM)にあるという見解を提唱しています。',
			description:
				'AIエージェントの未来は、大きなLLMよりも小さなSLMにあります。現在、多くのAIエージェントがLLMを使っていますが、その仕事の多くはシンプルで繰り返されるため、SLMでも十分な能力を発揮できます。SLMはLLMに比べて、費用が安く、処理が速く、より柔軟に使えるという大きな利点があります。将来的には、複雑なタスクにはLLM、シンプルなタスクにはSLMを使い分ける組み合わせ型が主流になるでしょう。',
			referenceUrl: 'https://arxiv.org/pdf/2506.02153',
			publishedOn: new Date()
		}
	]
});

ミドルウェアの実装、設定

データベースへのアクセスを担うミドルウェアや認証チェック処理はよく使うので、
ユーティリティとして定義しておくと便利です。

// src/lib/api/router/util.ts

import { ORPCError, os, onError, ValidationError } from '@orpc/server';
import { type PrismaClient, Prisma } from '@prisma/client';
import { default as createPrismaMock } from 'prisma-mock/client';
import type { User } from '../type';

// コンテキストの定義
export interface ORPCContext {
	user?: User;
	prisma: PrismaClient;
}

const dbProviderMiddleware = os
	.$context<{
		prisma: PrismaClient;
	}>()
	.middleware(async ({ context, next }) => {
        // OpenAPIの仕様書生成のためにモックが必要です
		const prisma: PrismaClient = context.prisma ?? createPrismaMock(Prisma);

		return next({
			context: {
				prisma
			}
		});
	});

// 未ログインエンドポイントの実装に使う
export const pub = os
	.$context<ORPCContext>()
	.use(dbProviderMiddleware)
	.use(
		onError((error) => {
			if (
				error instanceof ORPCError &&
				error.code === 'INTERNAL_SERVER_ERROR' &&
				error.cause instanceof ValidationError
			) {
				// You can now access error.cause.issues and error.cause.data
				// Attach them to your error response, log them, etc.
				console.error(error);
			}
		})
	);

// ログインが必要なエンドポイントに使う
export const authed = pub.use(({ context, next }) => {
	if (!context.user) {
		throw new ORPCError('UNAUTHORIZED');
	} else {
		return next({
			context: {
				//
				user: context.user
			}
		});
	}
});

エンドポイントの実装

定義したスキーマとユーティリティを使って実装していきます。

// src/lib/api/router/v1/paper.ts

import { z } from 'zod';
import { authed } from '../util';
import { PaperSummarySchema, AuthHeaderSchema } from '../../schema';

// 論文の一覧取得
export const listPapers = authed
	.route({
		method: 'GET',
		path: '/v1/papers',
		summary: 'List Papers',
		tags: ['Papers'],
        // OpenAPIの仕様書がうまく生成できないので、
        // compactではなくdetailedを指定してリクエストの構造を明確に定義します
        inputStructure: 'detailed'
	})
	.input(
		z.object({
			query: z.object({
				limit: z.number().int().min(1).max(100).default(50),
				cursor: z.number().int().min(0).default(0)
			}),
			headers: AuthHeaderSchema
		})
	)
	.output(z.array(PaperSummarySchema))
	.handler(async ({ input, context: { prisma } }) => {
		const cursor = input.query.cursor;
		const limit = input.query.limit;

		const data = await prisma.paper.findMany({
			orderBy: {
				createdAt: 'desc'
			},
			take: limit,
			skip: cursor
		});

		return data;
	});

SveltekitのroutesでAPIサーバーとして動かす

公式 の実装に則って、特定のパス配下についてはoRPCのハンドラ関数で処理する様に実装します。
(Sveltekitでこんな実装ができると気づけて、面白いですね)

// src/routes/api/[...rest]/+server.ts

import { OpenAPIHandler } from '@orpc/openapi/fetch';
import { router } from '../../../lib/api/router';
import { onError } from '@orpc/server';
import type { RequestHandler } from '@sveltejs/kit';
import { ZodToJsonSchemaConverter } from '@orpc/zod/zod4';
import { experimental_SmartCoercionPlugin as SmartCoercionPlugin } from '@orpc/json-schema';
import { PrismaClient } from '@prisma/client';

const handler = new OpenAPIHandler(router, {
	interceptors: [
		onError((error) => {
			console.error(error);
		})
	],
	plugins: [
		new SmartCoercionPlugin({
            // zodのスキーマを変換するプラグインを指定する
			schemaConverters: [new ZodToJsonSchemaConverter()]
		})
	]
});

const handle: RequestHandler = async ({ request, locals }) => {

	const prisma = new PrismaClient({});

	const { response } = await handler.handle(request, {
        // /api配下でAPIを提供します
		prefix: '/api',
        // 実際のコンテキストの値をoRPCのハンドラに設定する
		context: { user: locals.user, prisma }
	});

	return response ?? new Response('Not Found', { status: 404 });
};

export const GET = handle;
export const POST = handle;
export const PUT = handle;
export const PATCH = handle;
export const DELETE = handle;

hooks.serverの修正

/api 配下だけサーバーでのみ使うリソースを設定したり、
フロントエンドからAPIを呼びだせる様にCORSヘッダの解決を自前で実装したりします。

(ユーザーが表示するページはクッキーによる認証、API呼び出しの認証はBearerトークンによる認証となる様にhooks内で分けていますが、その辺の実装は割愛…)

// src/hooks.server.ts

import { sequence } from '@sveltejs/kit/hooks';
import type { Handle } from '@sveltejs/kit';

import { prepareServer } from './lib/api/server';
import { CORS_ALLOWED_ORIGINS } from '$env/static/private';

const handleServer: Handle = async ({ event, resolve }) => {
	// api配下のみでサーバーのセットアップを実行
	if (event.url.pathname.startsWith('/api/')) {
        // database URLやストレージサーバーへのアクセス方法を解決
		await prepareServer();
	}

	return resolve(event);
};


const handleCors: Handle = async ({ event, resolve }) => {
	const origin = event.request.headers.get('origin');
	const isAllowed = origin && allowedOrigins.includes(origin);
	const requestedHeaders = event.request.headers.get('Access-Control-Request-Headers') ?? '';

	if (event.request.method === 'OPTIONS') {
		return new Response(null, {
			headers: {
				'Access-Control-Allow-Origin': isAllowed ? origin : '',
				'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
				'Access-Control-Allow-Headers': requestedHeaders,
				'Access-Control-Allow-Credentials': 'true',
				Vary: 'Origin'
			}
		});
	}

	const response = await resolve(event);

	if (isAllowed) {
		response.headers.set('Access-Control-Allow-Origin', origin);
		response.headers.set('Access-Control-Allow-Credentials', 'true');
		response.headers.set('Vary', 'Origin');
	}

	return response;
};

export const handle: Handle = sequence(handleCors, handleServer, handleSecurity);

ここまで実装することで、Sveltekit + oRPCで動作するAPIサーバーができました。

API定義の生成

以下の様なコードを実行すると、API定義を生成することができます。

import { OpenAPIGenerator } from '@orpc/openapi';
import { ZodToJsonSchemaConverter } from '@orpc/zod/zod4';
import { router } from './router';

async function main() {
	const generator = new OpenAPIGenerator({
		schemaConverters: [new ZodToJsonSchemaConverter()]
	});

	const spec = await generator.generate(router, {
		info: {
			title: 'orpc example API',
			version: '0.1.0',
			description: 'Hosted API on orpc example Server'
		}
	});

	console.log(JSON.stringify(spec, null, 2));
}

main();

tsxなどを使ってTypescriptのコードを実行します。

# 	"scripts": {
#		"openapi": "tsx src/lib/api/openapi.ts > ../openapi.yaml"
#	},
pnpm run openapi

OpenAPI Specificationのjsonファイルができました。やったね。

{
  "info": {
    "title": "orpc example API",
    "version": "0.1.0",
    "description": "Hosted API on orpc example Server"
  },
  "openapi": "3.1.1",
  "paths": {
    "/v1/papers": {
      "get": {
        "operationId": "v1.paper.list",
        "summary": "List Papers",
        "tags": [
          "Papers"
        ],
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 50
            },
            "allowEmptyValue": true,
            "allowReserved": true
          },
          {
            "name": "cursor",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "maximum": 9007199254740991,
              "default": 0
            },
            "allowEmptyValue": true,
            "allowReserved": true
          },
          {
            "name": "Authorization",
            "in": "header",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "properties": {
                      "id": {
                        "type": "integer",
                        "minimum": 1,
                        "maximum": 9007199254740991
                      },
                      "title": {
                        "type": "string"
                      },
                      "summary": {
                        "type": "string"
                      },
                      "description": {
                        "type": "string"
                      },
                      "referenceUrl": {
                        "type": "string",
                        "format": "uri"
                      },
                      "publishedOn": {
                        "type": "string",
                        "format": "date-time",
                        "x-native-type": "date"
                      }
                    },
                    "required": [
                      "id",
                      "title",
                      "summary",
                      "description",
                      "referenceUrl",
                      "publishedOn"
                    ],
                    "examples": [
                      {
                        "id": 1,
                        "title": "Small Language Models are the Future of Agentic AI",
                        "summary": "エージェントAIの将来が小規模言語モデル(SLM)にあるという見解を提唱しています。",
                        "description": "AIエージェントの未来は、大きなLLMよりも小さなSLMにあります。現在、多くのAIエージェントがLLMを使っていますが、その仕事の多くはシンプルで繰り返されるため、SLMでも十分な能力を発揮できます。SLMはLLMに比べて、費用が安く、処理が速く、より柔軟に使えるという大きな利点があります。将来的には、複雑なタスクにはLLM、シンプルなタスクにはSLMを使い分ける組み合わせ型が主流になるでしょう。",
                        "referenceUrl": "https://arxiv.org/pdf/2506.02153",
                        "publishedOn": "2025-12-04T13:16:54.138Z"
                      }
                    ]
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}

この定義をフロントエンドで利用すれば、RustでAPIを作り直す際にインターフェイスの間違え起因でバグを生み出すことがなさそうなので、安心です。

(Rustに作り替えたくなるくらいサービスがスケールする様に頑張ります)

最後まで読んでいただいてありがとうございました。

5
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?