0
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?

実践JSON Schema: TypeScriptでAPIレスポンスを安全に検証する

Posted at

この記事は以下の記事を元に、JSON Schemaの実装に焦点を当てて再構築したものです:

はじめに

TypeScriptでAPIサーバーを開発していると、「実行時にもレスポンスの型を検証したい」というニーズに出会います。コンパイル時の型チェックは強力ですが、実行時の型安全性は別の仕組みが必要です。

この記事では、JSON Schemaを使って実行時のレスポンス検証を実装する方法を紹介します。コンパイル時の型定義と実行時の検証を組み合わせることで、より堅牢なAPIサーバーを作ることができます。

このような課題を解決します

  • APIレスポンスの型が実行時に想定通りか確認したい
  • 型エラーを開発者が理解しやすい形で報告したい
  • 本番環境でも高速に動作する検証を実装したい

実装の概要

実装のポイントは以下の3つです:

  1. TypeScriptの型定義からJSON Schemaを生成
  2. AjvによるJSON Schema検証の実装
  3. 開発・本番環境それぞれに適した最適化

型定義からJSON Schemaへ

まず、TypeScriptの型定義からJSON Schemaを生成します:

// レスポンスの型定義
interface UserResponse {
  id: number;
  name: string;
  email: string;
  age?: number;
  createdAt: string;  // ISO 8601形式の日付文字列
}

// JSON Schema
const userResponseSchema = {
  type: 'object',
  required: ['id', 'name', 'email', 'createdAt'],
  properties: {
    id: { type: 'number' },
    name: { type: 'string' },
    email: { type: 'string', format: 'email' },
    age: { type: 'number' },
    createdAt: { type: 'string', format: 'date-time' }
  },
  additionalProperties: false  // 余分なプロパティを許可しない
} as const;

バリデーション実装

Ajvを使って検証機能を実装します:

import Ajv from 'ajv';
import addFormats from 'ajv-formats';

class ResponseValidator {
  private ajv: Ajv;
  private compiledSchemas: Map<string, any>;
  
  constructor() {
    this.ajv = new Ajv({
      allErrors: true,    // すべてのエラーを収集
      verbose: true,      // 詳細なエラー情報を取得
      removeAdditional: true,  // 余分なプロパティを削除
      useDefaults: true,  // デフォルト値を設定
      coerceTypes: false  // 型の自動変換を無効化
    });
    
    // 日付やメールなどの標準フォーマットを追加
    addFormats(this.ajv);
    
    // コンパイル済みスキーマのキャッシュ
    this.compiledSchemas = new Map();
  }

  // スキーマのコンパイルとキャッシュ
  private getSchema(key: string, schema: object) {
    let validate = this.compiledSchemas.get(key);
    if (!validate) {
      validate = this.ajv.compile(schema);
      this.compiledSchemas.set(key, validate);
    }
    return validate;
  }

  // エラーメッセージの整形
  private formatError(error: any): string {
    const { instancePath, message, params } = error;
    const path = instancePath || 'レスポンス';
    
    switch (error.keyword) {
      case 'type':
        return `${path}の型が不正です。${params.type}が期待されましたが、${typeof params.data}が渡されました。`;
      case 'required':
        return `${path}に必須フィールド「${params.missingProperty}」がありません。`;
      case 'format':
        return `${path}の形式が不正です。${params.format}形式で入力してください。`;
      case 'additionalProperties':
        return `${path}に不要なフィールド「${params.additionalProperty}」が含まれています。`;
      default:
        return `${path}: ${message}`;
    }
  }

  // 検証の実行
  validate<T>(key: string, schema: object, data: unknown): {
    valid: boolean;
    data: T | null;
    errors: string[];
  } {
    const validate = this.getSchema(key, schema);
    const valid = validate(data);
    
    if (!valid && validate.errors) {
      return {
        valid: false,
        data: null,
        errors: validate.errors.map(this.formatError)
      };
    }
    
    return {
      valid: true,
      data: data as T,
      errors: []
    };
  }
}

使用例

実際に使ってみましょう:

const validator = new ResponseValidator();

// 正しいレスポンス
const validResponse = {
  id: 1,
  name: "山田太郎",
  email: "yamada@example.com",
  createdAt: "2024-01-08T10:30:00Z"
};

// 検証実行
const result = validator.validate<UserResponse>(
  'user',
  userResponseSchema,
  validResponse
);
console.log(result);
// => {
//   valid: true,
//   data: {
//     id: 1,
//     name: "山田太郎",
//     email: "yamada@example.com",
//     createdAt: "2024-01-08T10:30:00Z"
//   },
//   errors: []
// }

// 不正なレスポンス
const invalidResponse = {
  id: "1",  // 数値であるべき
  name: "山田太郎",
  // emailが欠落
  age: "30",  // 数値であるべき
  createdAt: "invalid-date",  // ISO 8601形式であるべき
  extraField: "不要なフィールド"  // 余分なフィールド
};

const result2 = validator.validate<UserResponse>(
  'user',
  userResponseSchema,
  invalidResponse
);
console.log(result2);
// => {
//   valid: false,
//   data: null,
//   errors: [
//     'レスポンス.id: 型が不正です。numberが期待されましたが、stringが渡されました。',
//     'レスポンスに必須フィールド「email」がありません。',
//     'レスポンス.age: 型が不正です。numberが期待されましたが、stringが渡されました。',
//     'レスポンス.createdAt: 形式が不正です。date-time形式で入力してください。',
//     'レスポンスに不要なフィールド「extraField」が含まれています。'
//   ]
// }

パフォーマンス最適化

本番環境での運用を見据えた最適化ポイントをいくつか紹介します:

// 環境に応じた設定
const isDev = process.env.NODE_ENV === 'development';

const validator = new ResponseValidator({
  // 開発環境では詳細な検証、本番環境では必要最小限の検証
  allErrors: isDev,
  verbose: isDev,
  validateFormats: isDev,
  
  // 共通の最適化設定
  strictTypes: true,  // より厳密な型チェック
  strictRequired: true  // より厳密なrequiredチェック
});

// スキーマの事前コンパイル
const schemas = {
  user: userResponseSchema,
  // ... その他のスキーマ
};

Object.entries(schemas).forEach(([key, schema]) => {
  validator.addSchema(key, schema);
});

発展的な使い方

1. 再利用可能なサブスキーマ

共通で使用する型のスキーマを定義し、再利用することができます:

const timestampSchema = {
  type: 'object',
  required: ['createdAt', 'updatedAt'],
  properties: {
    createdAt: { type: 'string', format: 'date-time' },
    updatedAt: { type: 'string', format: 'date-time' }
  }
} as const;

const userResponseSchema = {
  type: 'object',
  required: ['id', 'name'],
  properties: {
    id: { type: 'number' },
    name: { type: 'string' },
    ...timestampSchema.properties  // タイムスタンプ項目を展開
  },
  required: [...timestampSchema.required, 'id', 'name']
} as const;

2. カスタムバリデーション

独自のバリデーションルールを追加することもできます:

// カスタムキーワードの定義
ajv.addKeyword({
  keyword: 'isPostalCode',
  type: 'string',
  validate: (schema: boolean, data: string) => {
    return schema && /^\d{3}-\d{4}$/.test(data);
  }
});

// カスタムフォーマットの定義
ajv.addFormat('phone', {
  type: 'string',
  validate: (data: string) => {
    return /^0\d{1,4}-\d{1,4}-\d{4}$/.test(data);
  }
});

まとめ

JSON Schemaを使ったレスポンス検証の利点は以下の通りです:

  1. 型定義と実行時の検証を一元管理できる
  2. 詳細なエラーメッセージで開発効率が向上する
  3. パフォーマンスを調整しやすい

実装時のポイントは:

  • 環境に応じた適切な設定を選択する
  • スキーマの再利用で保守性を高める
  • エラーメッセージは開発者の視点で分かりやすく

参考リンク

公式ドキュメント:

代替アプローチ:

0
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
0
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?