FirebaseStorage(CloudStorage)をEdgeRuntimeで操作する
FirebaseStorageを操作する方法
一般的な方法として、FirebaseStorageを操作する方法は以下のようになります。
OAuthを使う場合
@firebase/storageパッケージを使うPrivateKeyを使う場合
firebase-adminパッケージを使う
今回やりたいのはPrivateKeyを使って、追加で認証を挟まずアクセス可能にする方法です。さらにNext.jsのEdgeRuntime対応です。一般的にはfirebase-adminを使うことになるのですが、このライブラリはNode.jsのフル機能を要求するため、EdgeRuntimeには対応していません。
要件を満たすものを探したのですがなかなか見つけられず、そうなると自分で作った方が早いという結論に至りました。
npmでパッケージ化したもの
https://www.npmjs.com/package/firebase-storage
RestAPIによるFirebaseStorageの操作
仕様は以下のところで公開されています。
https://cloud.google.com/storage/docs/json_api/v1
ということで早速作っていきましょう。
認証
FirebaseStorageをRestAPIで操作しようとする時、初っ端にそびえる最大の関門です。ドキュメントを見ても、まともに説明されていません。
必要なのはPrivateKeyとClientEmailからJWTでキーを作成することです。ここで問題になるのはcryptoのパッケージはEdgeRuntimeでは動かないということです。JWTの操作はcryptoを使っていないものを選ぶ必要があります。そのため、Edgeに対応しているjoseを使います。
認証用Tokenを使った最小サンプルは以下のようになります。
1import { SignJWT, importPKCS8 } from "jose";23export const createToken = ({4 clientEmail,5 privateKey,6}: {7 clientEmail: string;8 privateKey: string;9}) =>10 importPKCS8(privateKey, "RS256").then((key) =>11 new SignJWT({12 iss: clientEmail,13 sub: clientEmail,14 scope: "https://www.googleapis.com/auth/cloud-platform",15 iat: Math.floor(Date.now() / 1000) - 30,16 exp: Math.floor(Date.now() / 1000) + 3600,17 })18 .setProtectedHeader({ alg: "RS256", typ: "JWT" })19 .sign(key)20 );
RestAPIの呼び出し
残りは普通にRestAPIで操作するだけなので、まとめて紹介します。
1export interface StorageObject {2 kind: string;3 id: string;4 selfLink: string;5 mediaLink: string;6 name: string;7 bucket: string;8 generation: string;9 metageneration: string;10 contentType: string;11 storageClass: string;12 size: string;13 md5Hash: string;14 cacheControl: string;15 crc32c: string;16 etag: string;17 timeCreated: string;18 updated: string;19 timeStorageClassUpdated: string;20}2122export const info = ({23 token,24 bucket,25 name,26}: {27 token: string;28 bucket: string;29 name: string;30}): Promise<StorageObject> => {31 const url = `https://storage.googleapis.com/storage/v1/b/${bucket}/o/${name}`;32 return fetch(url, {33 method: "GET",34 headers: {35 Authorization: `Bearer ${token}`,36 },37 }).then((res) => {38 if (res.status !== 200) throw new Error(res.statusText);39 return res.json();40 });41};4243export const download = ({44 token,45 bucket,46 name,47}: {48 token: string;49 bucket: string;50 name: string;51}) => {52 const url = `https://storage.googleapis.com/storage/v1/b/${bucket}/o/${name}?alt=media&no=${Date.now()}`;53 return fetch(url, {54 method: "GET",55 headers: {56 Authorization: `Bearer ${token}`,57 },58 }).then((res) => {59 if (res.status !== 200) throw new Error(res.statusText);60 return res.arrayBuffer();61 });62};6364export const upload = ({65 token,66 bucket,67 name,68 file,69 published,70 metadata,71}: {72 token: string;73 bucket: string;74 name: string;75 file: Blob;76 published?: boolean;77 metadata?: { [key: string]: unknown };78}) => {79 const id = encodeURI(name);8081 const url = `https://storage.googleapis.com/upload/storage/v1/b/${bucket}/o?name=${id}&uploadType=multipart${82 published ? "&predefinedAcl=publicRead" : ""83 }`;84 const body = new FormData();85 body.append(86 "",87 new Blob([JSON.stringify({ metadata })], { type: "application/json" })88 );89 body.append("", file);90 return fetch(url, {91 method: "POST",92 headers: {93 Authorization: `Bearer ${token}`,94 },95 body: body,96 }).then((res) => {97 if (res.status !== 200) throw new Error(res.statusText);98 return res.json();99 });100};101102export const del = ({103 token,104 bucket,105 name,106}: {107 token: string;108 bucket: string;109 name: string;110}) => {111 const url = `https://storage.googleapis.com/storage/v1/b/${bucket}/o/${name}`;112 return fetch(url, {113 method: "DELETE",114 headers: {115 Authorization: `Bearer ${token}`,116 },117 }).then((res) => {118 if (res.status !== 204) throw new Error(res.statusText);119 return true;120 });121};122123export const list = ({124 token,125 bucket,126}: {127 token: string;128 bucket: string;129}): Promise<StorageObject[]> => {130 const url = `https://storage.googleapis.com/storage/v1/b/${bucket}/o`;131 return fetch(url, {132 headers: {133 Authorization: `Bearer ${token}`,134 },135 })136 .then((res) => {137 if (res.status !== 200) throw new Error(res.statusText);138 return res.json();139 })140 .then((res) => res.items);141};
ちなみにオブジェクトのデータを取得する時、StorageObjectのような構造になっているのですが、注意点として全て文字列型だということです。
https://cloud.google.com/storage/docs/json_api/v1/objects
上記ドキュメントには文字列以外の型が書いてありますが嘘です。全部文字列です。必要なら自分で型を変換してください。
その他の点として、upload時にmetadataを同時指定するのであればmultipartでデータを送る必要があります。データをFormDataにセットするだけなので、やり方さえ知っていれば簡単です。
まとめ
コード的には大した量にならず、簡単な内容で処理を書くことが出来ます。ただ、ドキュメントから具体的な使い方を理解するまでがそれなりに面倒です。