メインコンテンツまでスキップ

OpenAPI仕様を生成する

TypeScriptデコレータを使ってOpenAPI specを自動生成し、REST APIを公開します。

OpenAPIエンドポイントを使う理由

Squid Client SDK を利用できないサードパーティ連携、モバイルクライアント、外部サービス向けに、バックエンドを標準的な REST API として公開する必要があります。

OpenAPI がない場合、ルートを手動で定義し、specファイルを書き、CORS を設定し、ドキュメントを同期し続けなければなりません。Squid の OpenAPI サポートを使えば、メソッドにデコレータを付けるだけで、完全にドキュメント化された REST API を得られます。

// Backend: just decorators on a service method
@Route('orders')
export class OrderService extends SquidService {
@Get('{orderId}')
async getOrder(@Path() orderId: string): Promise<Order> {
const order = await this.fetchOrder(orderId);
return this.createOpenApiResponse(order);
}
}

// Consumers call it as a standard REST endpoint:
// GET https://<your-app>.squid.cloud/openapi/orders/abc123

specの手書きは不要。ルート設定も不要。デコレータ付きメソッドだけでOKです。

概要

Squid は tsoa デコレータを使用して、TypeScript コードから OpenAPI specifications を生成します。SquidService のサブクラス上のメソッドとしてエンドポイントを定義すると、Squid が spec を生成し、ルーティングを処理し、API を提供します。

OpenAPIエンドポイントを使うタイミング

ユースケース推奨
外部コンシューマ向けに REST API を公開するOpenAPI endpoint
Squid Client SDK からバックエンド関数を呼び出すExecutables を使用
データベース変更に反応するTriggers を使用
外部サービスからの HTTP コールバックを受け取るWebhooks を使用

仕組み

  1. SquidService を拡張したクラスに @Route を追加し、メソッドに HTTP メソッドデコレータを付与します
  2. Squid はデプロイ時にデコレートされたメソッドを検出し、OpenAPI spec を生成します
  3. 外部コンシューマは標準的な HTTP リクエストでエンドポイントを呼び出します
  4. Squid がリクエストをルーティングし、パラメータを抽出して、あなたのメソッドを呼び出します
  5. createOpenApiResponse() を使ってレスポンスを返します

クイックスタート

前提条件

  • squid init で初期化された Squid backend プロジェクト
  • @squidcloud/backend パッケージがNPMからインストール済み

Step 1: OpenAPIエンドポイントを作成する

サービスクラスに @Route デコレータを追加し、メソッドに HTTP メソッドデコレータを付けます。

Backend code
import { SquidService } from '@squidcloud/backend';
import { Get, Query, Route } from 'tsoa';

@Route('example')
export class ExampleService extends SquidService {
@Get('echo')
async echo(@Query() message: string): Promise<string> {
return this.createOpenApiResponse(message);
}
}

Step 2: サービスをエクスポートする

サービスが service の index ファイルからエクスポートされていることを確認します。

service/index.ts
export * from './example-service';

Step 3: backend をデプロイする

squid deploy

Step 4: エンドポイントを呼び出す

curl "https://YOUR_APP_ID-dev.APP_REGION.squid.cloud/openapi/example/echo?message=hello"

レスポンスボディは hello になります。

エンドポイントURL

OpenAPI では、使用するエンドポイントターゲットが 2 つあります。

OpenAPIエンドポイントの仕様(Specification)

OpenAPI エンドポイントの spec をダウンロードするには、ベースURLに /openapi/spec.json を付け足します。

エンドポイント本体

各エンドポイントは指定された route で利用でき、ベースURLに /openapi/{route}/{method} テンプレートを付けたURLでアクセスします。

ベースURL

ベースURLは、backend をどのように実行しているかによって決まります。

ローカル開発

ローカルで開発している場合、エンドポイントは次のベースURLを使用します。

https://[YOUR_APP_ID]-dev-[YOUR_SQUID_DEVELOPER_ID].[APP_REGION].squid.cloud

したがって、対応するエンドポイントは次のとおりです。

https://[YOUR_APP_ID]-dev-[YOUR_SQUID_DEVELOPER_ID].[APP_REGION].squid.cloud/openapi/spec.json
https://[YOUR_APP_ID]-dev-[YOUR_SQUID_DEVELOPER_ID].[APP_REGION].squid.cloud/openapi/{route}/{method}

デプロイ環境

デプロイ環境のベースURLは、developmentproduction のどちらを使うかで変わります。URL はわずかに異なり、development 環境の URL には app ID の後ろに -dev が含まれます。つまり次のようになります。

Dev:

https://[YOUR_APP_ID]-dev.[APP_REGION].squid.cloud

Prod:

https://[YOUR_APP_ID].[APP_REGION].squid.cloud

例えば Dev の場合、対応するエンドポイントは次のとおりです。

https://[YOUR_APP_ID]-dev.[APP_REGION].squid.cloud/openapi/spec.json
https://[YOUR_APP_ID]-dev.[APP_REGION].squid.cloud/openapi/{route}/{method}

モニタリング

Squid ConsoleBackend タブ内の OpenAPI から、OpenAPI controllers、specs、利用状況を確認できます。

コアコンセプト

HTTPメソッドデコレータ

Squid は tsoa デコレータにより、標準的な HTTP メソッドをすべてサポートします。

Backend code
import { SquidService } from '@squidcloud/backend';
import { Body, Delete, Get, Patch, Path, Post, Put, Route } from 'tsoa';

interface Item {
id: string;
name: string;
price: number;
}

interface CreateItemRequest {
name: string;
price: number;
}

@Route('items')
export class ItemService extends SquidService {
@Get('{itemId}')
async getItem(@Path() itemId: string): Promise<Item> {
const item: Item = { id: itemId, name: 'Widget', price: 9.99 };
return this.createOpenApiResponse(item);
}

@Post()
async createItem(@Body() data: CreateItemRequest): Promise<Item> {
const newItem: Item = { id: crypto.randomUUID(), ...data };
return this.createOpenApiResponse(newItem, 201);
}

@Put('{itemId}')
async replaceItem(@Path() itemId: string, @Body() data: Item): Promise<Item> {
const updatedItem: Item = { ...data, id: itemId };
return this.createOpenApiResponse(updatedItem);
}

@Patch('{itemId}')
async updateItem(@Path() itemId: string, @Body() data: Partial<Item>): Promise<Item> {
const updatedItem: Item = { id: itemId, name: data.name ?? 'Widget', price: data.price ?? 9.99 };
return this.createOpenApiResponse(updatedItem);
}

@Delete('{itemId}')
async deleteItem(@Path() itemId: string): Promise<void> {
console.log(`Deleting item ${itemId}`);
return this.createOpenApiResponse(undefined, 204);
}
}

パラメータデコレータ

HTTP リクエストのさまざまな箇所からデータを取り出します。

Decorator取得元
@Path()URLパスセグメント/items/{itemId}
@Query()クエリ文字列?status=active
@Body()リクエストボディJSON payload
@Header()HTTPヘッダーAuthorization header
@UploadedFile(fieldName)単一ファイルアップロードForm file input
@UploadedFiles(fieldName)複数ファイルアップロードMulti-file form input
@FormField()フォームフィールドデータForm text input

すべてのデコレータは tsoa から import します。

createOpenApiResponse()

このメソッドを使って、カスタムのステータスコードやヘッダーを含むレスポンスを構築できます。

Backend code
// 200 with body (default)
return this.createOpenApiResponse({ id: '123', name: 'Widget' });

// 201 Created with custom header
return this.createOpenApiResponse({ id: 'new-item' }, 201, { 'x-custom-header': 'created' });

// 204 No Content
return this.createOpenApiResponse(undefined, 204);

// 404 Not Found
return this.createOpenApiResponse({ error: 'Resource not found' }, 404);

Parameters:

ParameterTypeDescription
bodyunknown (optional)レスポンスのpayload
statusCodenumber (optional)HTTP status code。body がある場合はデフォルト 200、ない場合は 204
headersRecord<string, unknown> (optional)レスポンスヘッダー

throwOpenApiResponse()

このメソッドは、実行を即座に中断してレスポンスを返します。認可失敗などの早期終了に便利です。

Backend code
@Get('protected')
async protectedEndpoint(): Promise<string> {
const apiKey = this.context.headers?.['x-api-key'];
if (!apiKey) {
this.throwOpenApiResponse({ body: 'UNAUTHORIZED', statusCode: 401 });
}
return this.createOpenApiResponse('Secret data');
}

OpenAPI context

各 OpenAPI リクエストでは、this.context.openApiContext から生のリクエスト詳細にアクセスできます。

Backend code
@Get('debug')
async debugRequest(): Promise<object> {
const ctx = this.context.openApiContext!;
return this.createOpenApiResponse({
method: ctx.request.method, // 'get', 'post', etc.
path: ctx.request.path, // '/debug'
queryParams: ctx.request.queryParams,
headers: ctx.request.headers,
rawBody: ctx.request.rawBody, // Raw request body string
});
}

ファイル処理

ファイルをアップロードする:

Backend code
import { SquidService } from '@squidcloud/backend';
import { Post, Route, UploadedFile, UploadedFiles } from 'tsoa';

@Route('files')
export class FileService extends SquidService {
@Post('upload')
async uploadFile(@UploadedFile() file: Express.Multer.File): Promise<object> {
return this.createOpenApiResponse({
filename: file.originalname,
size: file.size,
mimetype: file.mimetype,
});
}

@Post('upload-multiple')
async uploadFiles(@UploadedFiles() files: Express.Multer.File[]): Promise<object> {
return this.createOpenApiResponse(files.map((f) => ({ name: f.originalname, size: f.size })));
}
}

ファイルを返す:

レスポンスの MIME type を指定するには @Produces デコレータを使います。

Backend code
import { SquidService } from '@squidcloud/backend';
import { Get, Produces, Route } from 'tsoa';

@Route('files')
export class FileDownloadService extends SquidService {
@Get('download')
@Produces('application/octet-stream')
async downloadFile(): Promise<File> {
const content = new Uint8Array([72, 101, 108, 108, 111]);
const file = new File([content], 'hello.txt', { type: 'text/plain' });
return this.createOpenApiResponse(file);
}
}

Spec ドキュメント用デコレータ

生成される OpenAPI spec に追加メタデータを付与します。

Decorator目的
@Tags('label')spec 内でエンドポイントをグルーピング@Tags('Users')
@Response(code, desc)取り得るレスポンスコードをドキュメント化@Response(404, 'Not found')
@Produces(mime)レスポンスの MIME type を指定@Produces('application/octet-stream')

認証と設定

tsoa.json で API spec を設定する

backend プロジェクトのルートに tsoa.json ファイルを作成し、spec 生成のカスタマイズや security scheme の定義を行います。

{
"entryFile": "src/service/index.ts",
"noImplicitAdditionalProperties": "throw-on-extras",
"controllerPathGlobs": ["src/**/*.ts"],
"spec": {
"outputDirectory": "dist",
"specVersion": 3,
"securityDefinitions": {
"apiKeyAuth": {
"type": "apiKey",
"name": "my-api-key-header",
"in": "header"
}
}
},
"routes": {
"routesDir": "dist",
"middlewareTemplate": "./node_modules/@squidcloud/local-backend/dist/local-backend/openapi-template.hbs"
}
}

エンドポイントにセキュリティ要件を付与する

生成される spec において、エンドポイントが認証必須であることを示すには @Security デコレータを使います。クラスレベル(全エンドポイント)またはメソッドレベルで適用できます。

Backend code
import { SquidService } from '@squidcloud/backend';
import { Get, Route, Security } from 'tsoa';

@Route('secure')
@Security('apiKeyAuth')
export class SecureService extends SquidService {
@Get('data')
async getData(): Promise<string> {
return this.createOpenApiResponse('Secure data');
}
}
Important

@Security デコレータは、OpenAPI spec 上で要件をドキュメント化するだけです。認証情報を検証するためのバリデーションロジックは、メソッド内で必ず実装する必要があります。

ランタイム認証

リクエスト context や組み込みの auth メソッドを使って、実行時に認証情報を検証します。

Backend code
import { SquidService } from '@squidcloud/backend';
import { Get, Route } from 'tsoa';

@Route('protected')
export class ProtectedService extends SquidService {
@Get('with-api-key')
async withApiKey(): Promise<string> {
const apiKey = this.context.headers?.['x-api-key'];
if (!apiKey || !Object.values(this.apiKeys).includes(apiKey)) {
this.throwOpenApiResponse({ body: 'UNAUTHORIZED', statusCode: 401 });
}
return this.createOpenApiResponse('Authenticated data');
}

@Get('with-bearer')
async withBearer(): Promise<string> {
// Use built-in Squid auth (requires Squid auth integration)
this.assertIsAuthenticated();
const user = this.getUserAuth();
return this.createOpenApiResponse(`Hello, ${user?.userId}`);
}
}

認証メソッドの詳細は、using auth in the backend を参照してください。

エラーハンドリング

エラーレスポンスを返す

適切なステータスコードとともに createOpenApiResponse() を使います。

Backend code
@Get('{itemId}')
async getItem(@Path() itemId: string): Promise<Item> {
if (!isValidId(itemId)) {
return this.createOpenApiResponse({ error: `Invalid id: ${itemId}` }, 400);
}

const item = await this.findItem(itemId);
if (!item) {
return this.createOpenApiResponse({ error: 'Item not found' }, 404);
}

return this.createOpenApiResponse(item);
}

throwOpenApiResponse() で実行を中断する

早期終了(例: 認証失敗)には、throwOpenApiResponse() を使って処理を即座に停止します。

Backend code
@Post('transfer')
async transfer(@Body() data: TransferRequest): Promise<object> {
if (!this.isAuthenticated()) {
this.throwOpenApiResponse({ body: 'UNAUTHORIZED', statusCode: 401 });
}

// This code only runs if authenticated
const result = await this.processTransfer(data);
return this.createOpenApiResponse(result);
}

未処理例外(Unhandled exceptions)

メソッドが未処理のエラーを throw した場合、Squid は 500 レスポンスを返し、エラーメッセージを body として返します。

Backend code
@Get('risky')
async riskyEndpoint(): Promise<string> {
throw new Error('Something went wrong');
// Returns: 500 with body "Something went wrong"
}

よくあるエラー

エラー原因解決策
404 OPENAPI_CONTROLLER_NOT_FOUNDroute パスがどの @Route にも一致しないURL が @Route とメソッドパスに一致しているか確認
500(エラーメッセージ付き)メソッド内の未処理例外try/catch と createOpenApiResponse() でエラー処理を追加
パラメータ不足必須パラメータがリクエストで提供されていないquery/path/body の params がデコレータ期待値と一致するか確認

ベストプラクティス

セキュリティ

  1. @Security は spec に要件を記載するだけなので、必ずランタイムで認証情報を検証する
  2. 認証失敗時は throwOpenApiResponse() を使って後続処理を止める
  3. 処理前に、type と size をチェックして ファイルアップロードを検証する

API設計

  1. 説明的な route パスを使う(例: @Route('users')@Route('u') は避ける)
  2. 適切なステータスコードを返す(作成は 201、削除は 204、不正入力は 400)
  3. @Tags を使って、生成される spec 内でエンドポイントを整理する
  4. 200 以外のステータスコードは @Response デコレータでレスポンスをドキュメント化する

パフォーマンス

  1. 必要なデータだけを返し、レスポンスpayloadを小さく保つ
  2. 入力を早期に検証して、不要な処理を避ける
  3. JSON 以外のレスポンスには @Produces を使って正しい content type を設定する

コード例

CRUD API

Backend code
import { SquidService } from '@squidcloud/backend';
import { Body, Delete, Get, Patch, Path, Post, Query, Response, Route, Tags } from 'tsoa';

interface Product {
id: string;
name: string;
price: number;
}

@Route('products')
@Tags('Products')
export class ProductService extends SquidService {
@Get()
@Response(200, 'List of products')
async listProducts(@Query() category?: string): Promise<Product[]> {
const products = this.squid.collection<Product>('products');
let query = products.query();
if (category) {
query = query.where('category', '==', category);
}
const results = await query.snapshot();
return this.createOpenApiResponse(results);
}

@Get('{productId}')
@Response(404, 'Product not found')
async getProduct(@Path() productId: string): Promise<Product> {
const ref = this.squid.collection<Product>('products').doc(productId);
const product = await ref.snapshot();
if (!product) {
return this.createOpenApiResponse({ error: 'Product not found' }, 404);
}
return this.createOpenApiResponse(product);
}

@Post()
@Response(201, 'Product created')
async createProduct(@Body() data: Omit<Product, 'id'>): Promise<Product> {
const id = crypto.randomUUID();
const product = { id, ...data };
await this.squid.collection<Product>('products').doc(id).insert(product);
return this.createOpenApiResponse(product, 201);
}

@Patch('{productId}')
async updateProduct(@Path() productId: string, @Body() data: Partial<Product>): Promise<Product> {
const ref = this.squid.collection<Product>('products').doc(productId);
await ref.update(data);
const updated = await ref.snapshot();
return this.createOpenApiResponse(updated);
}

@Delete('{productId}')
@Response(204, 'Product deleted')
async deleteProduct(@Path() productId: string): Promise<void> {
await this.squid.collection<Product>('products').doc(productId).delete();
return this.createOpenApiResponse(undefined, 204);
}
}

API key で保護されたエンドポイント

Backend code
import { SquidService } from '@squidcloud/backend';
import { Body, Post, Route, Security } from 'tsoa';

@Route('webhooks')
@Security('apiKeyAuth')
export class WebhookReceiverService extends SquidService {
@Post('ingest')
async ingestData(@Body() payload: Record<string, unknown>): Promise<object> {
// Validate API key at runtime
const apiKey = this.context.headers?.['x-api-key'];
if (!apiKey || !Object.values(this.apiKeys).includes(apiKey)) {
this.throwOpenApiResponse({ body: 'UNAUTHORIZED', statusCode: 401 });
}

// Process the payload
await this.squid.collection('events').doc(crypto.randomUUID()).insert({
payload,
receivedAt: new Date().toISOString(),
});

return this.createOpenApiResponse({ status: 'accepted' }, 201);
}
}

外部APIプロキシ

Backend code
import { SquidService } from '@squidcloud/backend';
import { Get, Query, Route } from 'tsoa';

@Route('weather')
export class WeatherProxyService extends SquidService {
@Get('current')
async getCurrentWeather(@Query() city: string): Promise<object> {
const apiKey = this.secrets['WEATHER_API_KEY'] as string;

const response = await fetch(`https://api.weather.example.com/v1/current?city=${encodeURIComponent(city)}`, { headers: { 'X-API-Key': apiKey } });

if (!response.ok) {
return this.createOpenApiResponse({ error: `Weather API returned ${response.status}` }, response.status);
}

const data = await response.json();
return this.createOpenApiResponse(data);
}
}

関連項目