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

ajv.compileSchema()がCSPに引っかかるので事前コンパイル(standaloneCode)した話

Posted at

先に結論

  1. ajv.compileSchema() は内部で new Function() しており、CSP(Content-Security-Policy)次第では動作しない。そこで standaloneCode を利用した事前ビルドが必要
  2. ビルド設定はviteを使う。ts-nodeなどでも可能だが、ビルド処理の一環として行いたいのでviteを選んだ
    ※正直に言えばts-nodeの設定が面倒だったから
  3. JSON Schema は JSONSchemaType を使えば TypeScript に型情報を付与できる。ただし、as const による静的 JSON 化が必要になる
  4. standaloneCode の出力は .cjs 形式になるため、import は動かず require() を使って読み込む必要がある。
  5. 型検証の代替として、定義済みの Schema 定数と .json を toEqual() で比較するテストを用意する

viteプラグイン

サンプルコード 例外処理などは省略しています。必要に応じて調整してください。MIT License で公開します。
// build/plugins/validateSchemaPlugin.ts
import fg from "fast-glob";
import fs from "fs/promises";
import path from "path";
import Ajv from "ajv";
import standaloneCode from "ajv/dist/standalone";
import { PluginOption } from "vite";

const build = async (schemaPath: string): Promise<void> => {
  const fullPath = path.resolve(schemaPath);

  // e.g. person/person.schema.json → person/validatePerson.ts
  const parsed = path.parse(fullPath);
  const fileBase = parsed.name.replace(/\.schema$/, ""); // e.g. "person"

  const filename = `${fileBase}Validate`;
  async function writeCjsFile() {
    const schemaText = await fs.readFile(fullPath, "utf-8");
    const schemaJson = JSON.parse(schemaText);

    const ajv = new Ajv({
      code: { source: true, esm: false },
      strict: true,
    });
    const validate = ajv.compile(schemaJson);
    const standalone: string = standaloneCode(ajv, validate);

    const targetFile = path.join(parsed.dir, `${filename}.cjs`);

    await fs.writeFile(targetFile, standalone, "utf-8");
  }

  async function writeDtsFile() {
    const dtsFilename = path.join(parsed.dir, `${filename}.d.ts`);
    const dtsContent: string = [
      `declare const validate: (data: unknown) => boolean;`,
      "export = validate;",
      "// This file is auto-generated by the validateSchemaPlugin.",
    ].join("\n");
    await fs.writeFile(dtsFilename, dtsContent, "utf-8");
  }

  writeCjsFile();
  writeDtsFile();
};

export function validateSchemaPlugin(): PluginOption {
  return {
    name: "vite-plugin-validate-schema",
    apply: "build",
    enforce: "pre",

    async buildStart() {
      console.log("バリデーション関数の生成を開始...");
      const schemaPaths = await fg("src/**/!(*.d).schema.json");

      for (const schemaPath of schemaPaths) {
        build(schemaPath);
      }
      console.log("バリデーション関数の生成が完了しました。");
    },
  };
}

1: 事前コンパイルの理由とメリット

ajv.compileSchema() は内部で new Function() を使用しており、これは CSP によって禁止される可能性があります。
CSP(Content-Security-Policy)は HTML の タグで設定可能で、セキュリティの観点から eval()new Function() のような動的コード生成を禁止できます。

そのため、AJV 公式でもstandaloneCode の使用が推奨されています。
また、事前にビルドすることでアプリケーションの起動速度も向上します。コンパイル処理が事前に済んでいるからです。

2: コード自動生成を準備する

  1. JSON Schema ファイルを fast-glob で収集
  2. JSON を読み込み
  3. AJV で validate 関数を生成
  4. .cjs ファイルとして出力
  5. .d.ts を併せて生成し、型情報を補完

.cjs を選んだのは ESM 出力でのテストトラブルを回避するためです。

3: 生成したコードを組み込む

.cjs ファイルは CommonJS 形式なので、TypeScript 側で型を与える必要があります。
詳細な型が必要な場合は validate() を呼び出す関数を外側に用意しましょう。

personValidate.cjs
 //自動生成された名状しがたいコード
personValidate.d.ts
declare const validate: (data: unknown) => boolean;
export = validate;
// This file is auto-generated by the validateSchemaPlugin.
validate.ts

import validate from "./personValidate";
import type { Person } from "./types";

export const isPerson = (data: unknown): data is Person => {
  return validate(data);
};

⚠️ Vitest など一部の環境では .cjs ファイルの import に失敗します。
その場合は require() を使用して回避してください。

validate.test.ts
import { describe, test, expect } from "vitest";
import { makeActorData } from "./actor";
const validate = require("./myValidate.cjs"); // Adjust the import based on your setup

4: JSON Schema を TypeScript 上で定義する

schema.ts
export const SCHEMA_PERSON = {
 type: "object",
 properties: {
   name: { type: "string" },
   age: { type: "integer", minimum: 0 },
 },
 required: ["name", "age"],
 additionalProperties: false,
} as const satisfies JSONSchemaType<{
 name: string;
 age: number;
}>;

satisfies により型の整合性を TypeScript 上でチェック可能になります。IDE 補完や型エラーも活用できるため強力です。

5: テストコードでJSONSchemaを検証する

JSON Schema の整合性は、次のように .json とコード内定義を toEqual() で比較することで確認できます。

schema.test.ts
import { test, expect } from "vitest";
import Ajv from "ajv"
import json from "./person.schema.json";
import { SCHEMA_PERSON } from "./schema";
import type {Person} from "./types"

test("SCHEMA_PERSON matches JSON schema", () => {
 expect(SCHEMA_PERSON).toEqual(json);
});

test("validate",()=>{
 const ajv = new Ajv()
 const validate = ajv.compileSchema(SCHEMA_PERSON);
 const person:Person = { name:"bob", age:20 };
})

終わりに

ajv の standaloneCode を用いた事前ビルド自体は専用の関数を呼び出すだけですが、形式・読み込み方法・テスト環境との兼ね合いでいくつかの落とし穴があります。
この記事が同様の構成に悩む方の参考になれば幸いです。

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