iimon TECH BLOG

iimonエンジニアが得られた経験や知識を共有して世の中をイイモンにしていくためのブログです

FirestoreRestAPIをWebアプリケーションで楽に使えるようにしておきたい

はじめに

iimonでエンジニアをしている腰丸です。 最近業務でFirestoreRestAPIを使用する機会があったので、使い方を考えてみました。
通常のSDKを用いたコーディングと比べて、そのまま使おうとすると実行したい操作ごとにリクエストやレスポンスの処理を記述する必要があり、
コード量やメンテナンスの面でなかなか辛いものがあるので、そこら辺をうまく解消できるように考えました。

FirestoreRestAPIの使い方自体は、弊社の木暮さんが記事を出してくれています。

tech.iimon.co.jp

やりたいこと

  • FirestoreAPI経由で操作するコレクションのスキーマを定義する専用のファイルを作る
  • Firestoreのコレクションスキーマの定義をすることでFirestoreRestAPIに対するインターフェイス(APIのやりとり)の処理は実装が終わっている状態にする。
  • 定義済みのコレクションのスキーマを使用することで型安全にする。

完成形のイメージ

export const noteCollection = firestoreCollection('note', {
  id: integerValue().withRequired(),
  title: stringValue(),
  contents: stringValue(),
  isActive: booleanValue(),
  someKeyList: arrayValue(stringValue()),
});
  • Firestoreに対する操作(例: ドキュメントの追加)
const handler = new FirestoreRestApiHandler('test-project', id_token);

const newNote: typeof noteCollection.$inferUpsert = {
  id: 123,
  title: 'test',
  contents: 'test',
  isActive: true,
  someKeyList: ['key1', 'key2'],
};

const res = handler.collection(noteCollection).createDocument(newNote);
  • コレクションの指定は、必ず定義済みのスキーマから参照させる

  • こんな感じで型定義を参照できて

  • 下記のように必須のフィールドを指定しないでドキュメントを作ろうとすると
const res = handler.collection(noteCollection).createDocument({
   title: 'hoge',
   contents: 'hoge',
});

Property 'id' is missing in type '{ title: string; contents: string; isActive: true; }' but required in type '{ id: number; }'.ts(2345)
のような警告を出してくれる

実装内容

ディレクトリ構成

├── FirestoreRestApiHandler.ts
├── api
│   ├── documents // ドキュメントに関するRestAPIのリクエスト定義
│   │   ├── batchGet.ts
│   │   ├── beginTransaction.ts
│   │   ├── createDocument.ts
│   │   ├── ...
├── collectionSchema
│   └── noteCollection.ts
├── collectionSchemaCreator.ts
├── helper
│   ├── firestoreFieldHelper.ts
│   └── firestoreParseHelper.ts
└── types
    ├── FirestorePrimitiveType.ts
    ├── FirestoreResponseType.ts
    ├── FirestoreRunQueryCondition.ts
    └── ParsedFirestoreDocumentResponseType.ts

下記に一部の実装を紹介します。

  • Firestoreのフィールドに対応する型を作成するためのヘルパー関数。
import { FirestorePrimitiveType } from '../types/FirestorePrimitiveType';

// 型推論で使用するため、eslint-disable-next-lineの警告を無効化
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export abstract class FirestoreField<T> {
  readonly kind!: string;
  required: boolean;
  options?: { itemType?: FirestoreField<FirestorePrimitiveType> };

  constructor(options?: { itemType?: FirestoreField<FirestorePrimitiveType> }) {
    this.required = false;
    this.options = options;
  }
}

export class FirestoreFieldWithRequired<T> extends FirestoreField<T> {
  readonly kind = 'required';
  required: boolean = true;
  constructor(options?: { itemType?: FirestoreField<FirestorePrimitiveType> }) {
    super(options);
  }
}

export class FirestoreFieldWithoutRequired<T> extends FirestoreField<T> {
  readonly kind = 'optional';
  required: boolean = false;
  constructor(options?: { itemType?: FirestoreField<FirestorePrimitiveType> }) {
    super(options);
  }

  withRequired(): FirestoreFieldWithRequired<T> {
    return new FirestoreFieldWithRequired(this.options);
  }
}

// 型定義用のヘルパー関数
export const stringValue = (): FirestoreFieldWithoutRequired<string> =>
  new FirestoreFieldWithoutRequired<string>();

export const integerValue = (): FirestoreFieldWithoutRequired<number> =>
  new FirestoreFieldWithoutRequired<number>();

export const booleanValue = (): FirestoreFieldWithoutRequired<boolean> =>
  new FirestoreFieldWithoutRequired<boolean>();

export const arrayValue = <T>(
  itemType: FirestoreField<T>
): FirestoreFieldWithoutRequired<T[]> =>
  new FirestoreFieldWithoutRequired<T[]>({ itemType: itemType });
  • Firestoreのコレクションを作成する関数
import {
  FirestoreField,
  FirestoreFieldWithoutRequired,
  FirestoreFieldWithRequired,
} from './helper/firestoreFieldHelper';
import { FirestorePrimitiveType } from './types/FirestorePrimitiveType';

export type FirestoreCollection<T> = {
  collectionName: string;
  fields: {
    [K in keyof T]: FirestoreField<T[K]>;
  };
  $inferUpsert: T;
};

export const firestoreCollection = <
  T extends Record<string, FirestoreField<FirestorePrimitiveType>>,
>(
  collectionName: string,
  fields: T
): FirestoreCollection<
  {
    [K in keyof T as T[K] extends FirestoreFieldWithRequired<FirestorePrimitiveType>
      ? K
      : never]: T[K] extends FirestoreFieldWithRequired<infer FieldType>
      ? FieldType
      : never;
  } & {
    // オプショナルなキー
    [K in keyof T as T[K] extends FirestoreFieldWithRequired<FirestorePrimitiveType>
      ? never
      : K]?: T[K] extends FirestoreFieldWithoutRequired<infer FieldType>
      ? FieldType
      : never;
  }
> => {
  return {
    collectionName,
    fields,
    $inferUpsert: {} as any,
  };
};
  {
    [K in keyof T as T[K] extends FirestoreFieldWithRequired<FirestorePrimitiveType>
      ? K
      : never]: T[K] extends FirestoreFieldWithRequired<infer FieldType>
      ? FieldType
      : never;
  } & {
    // オプショナルなキー
    [K in keyof T as T[K] extends FirestoreFieldWithRequired<FirestorePrimitiveType>
      ? never
      : K]?: T[K] extends FirestoreFieldWithoutRequired<infer FieldType>
      ? FieldType
      : never;
  }

の部分で、Firestoreのコレクションのスキーマを定義する際に、必須のフィールドとオプショナルなフィールドを分けて定義しています。

  • Firestore操作のハンドラークラス
import * as FirestoreRestApi from './api';
import { FirestoreCollection } from './collectionSchemaCreator';
import { ParsedFirestoreDocumentResponseType } from './types/ParsedFirestoreDocumentResponseType';
import { QueryCondition } from './types/FirestoreRunQueryCondition';

export class FirestoreRestApiHandler {
  private baseUrl: string;
  private authToken: string;

  constructor(projectId: string, authToken: string) {
    this.baseUrl = `https://firestore.googleapis.com/v1/projects/${projectId}/databases/(default)/documents`;
    this.authToken = authToken;
  }

  collection<T>(collectionDef: FirestoreCollection<T>): NoDocumentHandler<T> {
    return new NoDocumentHandler<T>(
      this.baseUrl,
      this.authToken,
      collectionDef
    );
  }
}

// 共通メソッドを持つベースクラス
abstract class BaseHandler<T> {
  constructor(
    protected baseUrl: string,
    protected authToken: string,
    protected collectionDef: FirestoreCollection<T>
  ) {}
}

// ドキュメントID未指定の状態
class NoDocumentHandler<T> extends BaseHandler<T> {
  async createDocument(
    data: T
  ): Promise<ParsedFirestoreDocumentResponseType<T>> {
    return FirestoreRestApi.createDocument(
      this.baseUrl,
      this.authToken,
      this.collectionDef,
      data
    );
  }

  document(id: string): WithDocumentHandler<T> {
    return new WithDocumentHandler<T>(
      id,
      this.baseUrl,
      this.authToken,
      this.collectionDef
    );
  }

  async getAllDocuments(): Promise<ParsedFirestoreDocumentResponseType<T>[]> {
    return FirestoreRestApi.getAllDocuments(
      this.baseUrl,
      this.authToken,
      this.collectionDef
    );
  }

  async findByConditions(
    conditions: QueryCondition<T>[]
  ): Promise<ParsedFirestoreDocumentResponseType<T>[]> {
    return FirestoreRestApi.runQuery(
      this.baseUrl,
      this.authToken,
      this.collectionDef,
      conditions
    );
  }
}

// ドキュメントID指定の状態
class WithDocumentHandler<T> extends BaseHandler<T> {
  constructor(
    private documentId: string,
    baseUrl: string,
    authToken: string,
    collectionDef: FirestoreCollection<T>
  ) {
    super(baseUrl, authToken, collectionDef);
  }

  async createDocumentWithDocumentId(
    data: T
  ): Promise<ParsedFirestoreDocumentResponseType<T>> {
    return FirestoreRestApi.createDocument(
      this.baseUrl,
      this.authToken,
      this.collectionDef,
      data,
      this.documentId
    );
  }
  async updateFieldValues(
    data: T // Firestoreの使用で、書き換えなのでコレクションの定義通りの型を期待する
  ): Promise<ParsedFirestoreDocumentResponseType<T>> {
    return FirestoreRestApi.updateFieldValues(
      this.baseUrl,
      this.authToken,
      this.collectionDef,
      data,
      this.documentId
    );
  }
  async deleteDocument(): Promise<void> {
    return FirestoreRestApi.deleteDocument(
      this.baseUrl,
      this.authToken,
      this.collectionDef,
      this.documentId
    );
  }
}
  • APIリクエスト処理の定義(例: createDocument.ts)
import { FirestoreCollection } from '@/firestoreRestApi/collectionSchemaCreator';
import { FirestoreField } from '@/firestoreRestApi/helper/firestoreFieldHelper';
import {
  convertToFirestoreFormat,
  parseSingleResponse,
} from '@/firestoreRestApi/helper/firestoreParseHelper';
import { FirestorePrimitiveType } from '@/firestoreRestApi/types/FirestorePrimitiveType';
import { FirestoreDocumentResponseType } from '@/firestoreRestApi/types/FirestoreResponseType';
import { ParsedFirestoreDocumentResponseType } from '@/firestoreRestApi/types/ParsedFirestoreDocumentResponseType';

/**
 * https://firebase.google.com/docs/firestore/reference/rest/v1/projects.databases.documents/createDocument?hl=ja&_gl=1*j8tbrp*_up*MQ..*_ga*NzMzNTU1OTQyLjE3MzMxNTA5MTg.*_ga_CW55HF8NVT*MTczMzE1MDkxOC4xLjAuMTczMzE1MDkxOC4wLjAuMA..
 *
 * @param baseUrl
 * @param authToken
 * @param collectionDef
 * @param data
 * @param documentId
 * @returns
 */
export async function createDocument<T>(
  baseUrl: string,
  authToken: string,
  collectionDef: FirestoreCollection<T>,
  data: Partial<T>,
  documentId?: string
): Promise<ParsedFirestoreDocumentResponseType<T>> {
  // 型チェック & 必須フィールドの検証
  const errors: string[] = [];
  Object.entries(collectionDef.fields).forEach(([key, field]) => {
    const fieldDef = field as FirestoreField<FirestorePrimitiveType>;
    const value = (data as any)[key];
    if (fieldDef.required && value === undefined) {
      errors.push(`Field "${key}" is required but missing.`);
    }
  });

  if (errors.length > 0) {
    throw new Error(`Validation failed: ${errors.join(', ')}`);
  }

  // データ形式の変換
  const firestoreData = convertToFirestoreFormat(data, collectionDef);

  const url = new URL(`${baseUrl}/${collectionDef.collectionName}`);
  // ドキュメントIDが存在する場合は、クエリパラメーターで指定
  if (documentId) {
    url.searchParams.append('documentId', documentId);
  }

  const response = await fetch(url, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${authToken}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ fields: firestoreData }),
  });

  if (!response.ok) {
    const errorData = await response.json();
    throw new Error(
      `Failed to insert document: ${errorData.error?.message || response.statusText}`
    );
  }

  const result: FirestoreDocumentResponseType<T> = await response.json();
  const paredResponse = parseSingleResponse(result);

  return paredResponse;
}
  • レスポンスの変換ヘルパー関数
import { FirestoreCollection } from '../collectionSchemaCreator';
import { ParsedFirestoreDocumentResponseType } from '../types/ParsedFirestoreDocumentResponseType';
import {
  FirestoreFieldValueType,
  FirestoreDocumentResponseType,
} from '../types/FirestoreResponseType';

export function convertToFirestoreValue(value: any): FirestoreFieldValueType {
  if (typeof value === 'string') {
    return { stringValue: value };
  } else if (typeof value === 'number') {
    return { integerValue: value.toString() };
  } else if (typeof value === 'boolean') {
    return { booleanValue: value };
  } else if (Array.isArray(value)) {
    return {
      arrayValue: {
        values: value.map(convertToFirestoreValue),
      },
    };
  }
  throw new Error(`Unsupported value type: ${typeof value}`);
}

export function convertToFirestoreFormat<T>(
  data: Partial<T>,
  collectionDef: FirestoreCollection<T>
): Record<string, any> {
  return Object.entries(data).reduce(
    (fields, [key, value]) => {
      const field = collectionDef.fields[key as keyof T];
      if (!field) return fields;

      fields[key] = convertToFirestoreValue(value);

      return fields;
    },
    {} as Record<string, any>
  );
}

// Firestoreのフィールドを変換
export function parseFirestoreFields<T>(
  fields: Record<keyof T, FirestoreFieldValueType>
): T {
  const parsed: Partial<T> = {};

  for (const [key, value] of Object.entries(fields)) {
    const checkValue = value as FirestoreFieldValueType;
    if ('stringValue' in checkValue) {
      parsed[key as keyof T] = checkValue.stringValue as T[keyof T];
    } else if ('integerValue' in checkValue) {
      parsed[key as keyof T] = parseInt(checkValue.integerValue) as T[keyof T];
    } else if ('booleanValue' in checkValue) {
      parsed[key as keyof T] = checkValue.booleanValue as T[keyof T];
    } else if ('arrayValue' in checkValue) {
      parsed[key as keyof T] = checkValue.arrayValue.values.map(v => {
        if ('stringValue' in v) return v.stringValue;
        if ('integerValue' in v) return parseInt(v.integerValue);
        if ('booleanValue' in v) return v.booleanValue;
        throw new Error(`Unsupported array value type`);
      }) as T[keyof T];
    } else {
      throw new Error(`Unsupported field type for key "${key}"`);
    }
  }

  return parsed as T;
}

export function parseSingleResponse<T>(
  response: FirestoreDocumentResponseType<T>
): ParsedFirestoreDocumentResponseType<T> {
  const name = response.name.split('/');
  if (!name) {
    throw new Error('ドキュメントのキーの取得に失敗しました。');
  }
  const lastIndex = name.length - 1;
  const documentId = name[lastIndex];

  const parsedFields = parseFirestoreFields<T>(response.fields);

  const paredResponse = {
    documentId: documentId,
    fields: parsedFields,
    createTime: response.createTime,
    updateTime: response.updateTime,
  };

  return paredResponse;
}

おわりに

今回は、FirestoreRestAPIを使用しましたが、 通常のSDKの場合でも、似たような仕組みで型安全なコーディングができるのかと思いました。

また、リクエストの変換処理自体は、そこまで難しくなかったのですが、 一度定義したオブジェクトの内容をから、フィールドにFIrestoreに対応するjsのデータ型と型の情報を持たせて、 型安全に使い回すのに苦労しました。(もっと良い書き方がある気がする)

最後まで読んでくださりありがとうございます!

この記事を読んで興味を持って下さった方がいらっしゃればカジュアルにお話させていただきたく、是非ご応募をお願いします。
iimon採用サイト / Wantedly / Green

次のアドベントカレンダーの記事はhogeさんです! どんな記事を書いてくれるのか楽しみですね!!!!!