ファイル/添付ファイルの操作
StorageカテゴリとGraphQL APIカテゴリを組み合わせて、特定のレコードに画像や動画などのファイルを関連付けることができます。例えば、プロフィール画像を持つUserモデルや、関連する画像を持つPostモデルを作成することができます。AmplifyのGraphQL APIおよびStorageカテゴリを使用すると、モデル自体の中でファイルを参照して関連付けを作成することができます。
プロジェクトのセットアップ
クイックスタートガイドの手順に従ってプロジェクトをセットアップしてください。
モデルの定義
amplify/data/resource.tsを開き、以下のモデルを追加してください:
import { type ClientSchema, a, defineData } from "@aws-amplify/backend";
const schema = a.schema({ Song: a .model({ id: a.id().required(), name: a.string().required(), coverArtPath: a.string(), }) .authorization((allow) => [allow.publicApiKey()]),});
export type Schema = ClientSchema<typeof schema>;
export const data = defineData({ schema, authorizationModes: { defaultAuthorizationMode: "apiKey",
apiKeyAuthorizationMode: { expiresInDays: 30, }, },});Storageのセットアップ
次に、Storageを設定し、アプリケーションのすべての認証済み(サインイン済み)ユーザーにアクセスを許可します。amplify/storage/resource.tsファイルを作成し、以下のコードを追加してください。これにより、ファイルアクセスがサインイン済みユーザーのみに制限されます。
import { defineStorage } from "@aws-amplify/backend";
export const storage = defineStorage({ name: "amplify-gen2-files", access: (allow) => ({ "images/*": [allow.authenticated.to(["read", "write", "delete"])], }),});以下のようにamplify/backend.tsファイルでStorageを設定してください:
import { defineBackend } from "@aws-amplify/backend";import { auth } from "./auth/resource";import { data } from "./data/resource";import { storage } from "./storage/resource";
export const backend = defineBackend({ auth, data, storage,});認可の設定
すべてのデータとファイルをパブリックにアクセス可能にする場合を除き、アプリケーションはStorageとDataの両方に対して読み取りおよび書き込みのための認可クレデンシャルが必要です。
StorageカテゴリとDataカテゴリはそれぞれ独自の認可パターンに基づいてデータアクセスを管理します。つまり、各カテゴリに対して適切な認可ロールを設定する必要があります。両カテゴリはAuthカテゴリを通じて設定された同じアクセスクレデンシャルを共有していますが、互いに独立して動作します。例えば、DataにAllow.authenticated()を追加しても、StorageカテゴリのファイルアクセスはGuardされません。同様に、Storageカテゴリに認可ルールを追加しても、APIのデータアクセスはGuardされません。
Storageを設定すると、AmplifyはCognito IDプールロールを使用してバケット上に適切なIAMポリシーを設定します。その後、認証済みユーザーとゲストユーザーがこれらのレベル内で限定的な権限を付与されるように、CRUDベース(作成、更新、読み取り、削除)の権限を追加することもできます。この設定を追加した後も、すべてのStorageアクセスはデフォルトでguestのままです。 誤ってパブリックアクセスされないよう、StorageアクセスレベルはStorageオブジェクト上でグローバルに設定するか、個々の関数呼び出しで設定する必要があります。このガイドでは前者のアプローチを使用し、すべてのStorageアクセスをauthenticatedユーザーに設定します。
各カテゴリの認可ルールを個別に設定できる機能により、データアクセスのより細かいコントロールが可能になり、柔軟性が高まります。認可パターンを混在させる必要があるシナリオでは、個々のStorage関数呼び出しにアクセスレベルを設定してください。例えば、所有者のみがアクセスすべきファイル(個人ファイルなど)にはentity_id CRUDアクセスを、ログイン済みの全ユーザーが共通ファイル(共有フォトアルバムの画像など)を閲覧できるようにするにはauthenticated読み取りアクセスを、全ユーザーがファイル(パブリックプロフィール画像など)を閲覧できるようにするにはguest読み取りアクセスを使用することができます。
Storageの認可レベルの設定方法の詳細については、Storageのドキュメントを参照してください。Dataの認可設定については、APIのドキュメントを参照してください。
関連ファイルを持つレコードの作成
Amplify Dataクライアントを使用してレコードを作成し、Storageにファイルをアップロードし、最後にレコードをアップロードしたファイルと関連付けることができます。以下の例では、Amplify Dataクライアントと、Amplify StorageライブラリのヘルパーであるuploudDataとgetUrlを使用して、レコードを作成し、ファイルをレコードに関連付けます。
import { generateClient } from "aws-amplify/api";import { uploadData, getUrl } from "aws-amplify/storage";import type { Schema } from "../amplify/data/resource";
// Generating the clientconst client = generateClient<Schema>({ authMode: "apiKey",});
// Create the API record:const response = await client.models.Song.create({ name: `My first song`,});
const song = response.data;
if (!song) return;
// Upload the Storage file:const result = await uploadData({ path: `images/${song.id}-${file.name}`, data: file, options: { contentType: "image/png", // contentType is optional },}).result;
// Add the file association to the record:const updateResponse = await client.models.Song.update({ id: song.id, coverArtPath: result?.path,});
const updatedSong = updateResponse.data;
setCurrentSong(updatedSong);
// If the record has no associated file, we can return early.if (!updatedSong.coverArtPath) return;
// Retrieve the file's signed URL:const signedURL = await getUrl({ path: updatedSong.coverArtPath });関連レコードのファイルの追加または更新
ファイルをレコードに関連付けるには、Storageのアップロードで返されたパスでレコードを更新します。以下の例では、Storageを使用してファイルをアップロードし、ファイルのパスでレコードを更新してから、画像をダウンロードするための署名付きURLを取得します。画像がすでにレコードに関連付けられている場合、レコードは新しい画像で更新されます。
import { generateClient } from "aws-amplify/api";import { uploadData, getUrl } from "aws-amplify/storage";import type { Schema } from "../amplify/data/resource";
// Generating the clientconst client = generateClient<Schema>({ authMode: "apiKey",});
// Upload the Storage file:const result = await uploadData({ path: `images/${currentSong.id}-${file.name}`, data: file, options: { contentType: "image/png", // contentType is optional },}).result;
// Add the file association to the record:const response = await client.models.Song.update({ id: currentSong.id, coverArtPath: result?.path,});
const updatedSong = response.data;
setCurrentSong(updatedSong);
// If the record has no associated file, we can return early.if (!updatedSong?.coverArtPath) return;
// Retrieve the file's signed URL:const signedURL = await getUrl({ path: updatedSong.coverArtPath });レコードのクエリと関連ファイルの取得
レコードに関連付けられたファイルを取得するには、まずレコードをクエリし、次にStorageを使用して署名付きURLを取得します。署名付きURLを使用してファイルをダウンロードしたり、画像を表示したりすることができます:
import { generateClient } from "aws-amplify/api";import { getUrl } from "aws-amplify/storage";import type { Schema } from "../amplify/data/resource";
// Generating the clientconst client = generateClient<Schema>({ authMode: "apiKey",});
const response = await client.models.Song.get({ id: currentSong.id,});
const song = response.data;
// If the record has no associated file, we can return early.if (!song?.coverArtPath) return;
// Retrieve the signed URL:const signedURL = await getUrl({ path: song.coverArtPath });APIレコードに関連するファイルの削除と除去
StorageファイルとGraphQL APIを操作する際によく使われる削除ワークフローは3つあります:
- ファイルの関連付けを削除し、ファイルとレコードの両方を保持し続ける。
- レコードの関連付けを削除してファイルを削除する。
- ファイルとレコードの両方を削除する。
ファイルの関連付けを削除し、ファイルとレコードの両方を保持し続ける
以下の例では、レコードからファイルの関連付けを削除しますが、S3からファイルは削除せず、データベースのレコードも削除しません。
import { generateClient } from "aws-amplify/api";import type { Schema } from "../amplify/data/resource";
// Generating the clientconst client = generateClient<Schema>({ authMode: "apiKey",});
const response = await client.models.Song.get({ id: currentSong.id,});
const song = response.data;
// If the record has no associated file, we can return early.if (!song?.coverArtPath) return;
const updatedSong = await client.models.Song.update({ id: song.id, coverArtPath: null,});レコードの関連付けを削除してファイルを削除する
以下の例では、レコードからファイルを削除し、次にS3からファイルを削除します:
import { generateClient } from "aws-amplify/api";import { remove } from "aws-amplify/storage";import type { Schema } from "../amplify/data/resource";
// Generating the clientconst client = generateClient<Schema>({ authMode: "apiKey",});const response = await client.models.Song.get({ id: currentSong.id,});const song = response?.data;
// If the record has no associated file, we can return early.if (!song?.coverArtPath) return;
// Remove associated file from recordconst updatedSong = await client.models.Song.update({ id: song.id, coverArtPath: null,});
// Delete the file from S3:await remove({ path: song.coverArtPath });ファイルとレコードの両方を削除する
import { generateClient } from "aws-amplify/api";import { remove } from "aws-amplify/storage";import type { Schema } from "../amplify/data/resource";
// Generating the clientconst client = generateClient<Schema>({ authMode: "apiKey",});const response = await client.models.Song.get({ id: currentSong.id,});
const song = response.data;
// If the record has no associated file, we can return early.if (!song?.coverArtPath) return;
await remove({ path: song.coverArtPath });
// Delete the record from the API:await client.models.Song.delete({ id: song.id });複数ファイルの操作
ユーザープロフィールに複数の画像を持たせるなど、1つのレコードに複数のファイルを追加したい場合があります。これを行うには、レコードにファイルキーのリストを追加できます。以下の例では、レコードにファイルキーのリストを追加します:
複数のファイルをデータモデルに関連付けるGraphQLスキーマ
amplify/data/resource.tsファイルに以下のモデルを追加してください。
const schema = a.schema({ PhotoAlbum: a .model({ id: a.id().required(), name: a.string().required(), imagePaths: a.string().array(), }) .authorization((allow) => [allow.publicApiKey()]),});複数のファイルを操作する際のCRUD操作は、単一のファイルキーではなくファイルキーのリストを操作する点を除いて、単一ファイルを操作する場合と同じです。
複数の関連ファイルを持つレコードの作成
まずGraphQL APIを使用してレコードを作成し、次にStorageにファイルをアップロードし、最後にレコードとファイルの関連付けを追加します。
import { generateClient } from "aws-amplify/api";import { uploadData, getUrl } from "aws-amplify/storage";import type { Schema } from "../amplify/data/resource";
// Generating the clientconst client = generateClient<Schema>({ authMode: "apiKey",});
// Create the API record:const response = await client.models.PhotoAlbum.create({ name: `My first photoAlbum`,});
const photoAlbum = response.data.createPhotoAlbum;
if (!photoAlbum) return;
// Upload all files to Storage:const imagePaths = await Promise.all( Array.from(e.target.files).map(async (file) => { const result = await uploadData({ path: `images/${photoAlbum.id}-${file.name}`, data: file, options: { contentType: "image/png", // contentType is optional }, }).result;
return result.path; }));
const updatePhotoAlbumDetails = { id: photoAlbum.id, imagePaths: imagePaths,};
// Add the file association to the record:const updateResponse = await client.graphql({ query: mutations.updatePhotoAlbum, variables: { input: updatePhotoAlbumDetails },});
const updatedPhotoAlbum = updateResponse.data.updatePhotoAlbum;
// If the record has no associated file, we can return early.if (!updatedPhotoAlbum.imageKeys?.length) return;
// Retrieve signed urls for all files:const signedUrls = await Promise.all( updatedPhotoAlbum?.imagePaths.map( async (path) => await getUrl({ path: path! }) ));関連レコードへの新しいファイルの追加
レコードに追加のファイルを関連付けるには、Storageのアップロードで返されたパスでレコードを更新します。
import { generateClient } from "aws-amplify/api";import { uploadData, getUrl } from "aws-amplify/storage";import type { Schema } from "../amplify/data/resource";
// Generating the clientconst client = generateClient<Schema>({ authMode: "apiKey",});
// Upload all files to Storage:const newimagePaths = await Promise.all( Array.from(e.target.files).map(async (file) => { const result = await uploadData({ path: `images/${currentPhotoAlbum.id}-${file.name}`, data: file, options: { contentType: "image/png", // contentType is optional }, }).result;
return result.path; }));
// Query existing record to retrieve currently associated files:const queriedResponse = await client.models.PhotoAlbum.get({ id: currentPhotoAlbum.id,});
const photoAlbum = queriedResponse.data;
if (!photoAlbum?.imagePaths) return;
// Merge existing and new file paths:const updatedimagePaths = [...newimagePaths, ...photoAlbum.imagePaths];
// Update record with merged file associations:const response = await client.models.PhotoAlbum.update({ id: currentPhotoAlbum.id, imagePaths: updatedimagePaths,});
const updatedPhotoAlbum = response.data;
// If the record has no associated file, we can return early.if (!updatedPhotoAlbum?.imageKeys) return;
// Retrieve signed urls for merged image paths:const signedUrls = await Promise.all( updatedPhotoAlbum?.imagePaths.map( async (path) => await getUrl({ path: path! }) ));関連レコードのファイルの更新
関連レコードのファイルを更新する操作は、単一ファイルレコードのファイルを更新する場合と同じですが、ファイルキーのリストを更新する必要がある点が異なります。
import { generateClient } from "aws-amplify/api";import { uploadData, getUrl } from "aws-amplify/storage";import type { Schema } from "../amplify/data/resource";
// Generating the clientconst client = generateClient<Schema>({ authMode: "apiKey",});
// Upload new file to Storage:const result = await uploadData({ path: `images/${currentPhotoAlbum.id}-${file.name}`, data: file, options: { contentType: "image/png", // contentType is optional },}).result;
const newFilePath = result.path;
// Query existing record to retrieve currently associated files:const queriedResponse = await client.models.PhotoAlbum.get({ id: currentPhotoAlbum.id,});
const photoAlbum = queriedResponse.data;
if (!photoAlbum?.imagePaths?.length) return;
// Retrieve last image path:const [lastImagePath] = photoAlbum.imagePaths.slice(-1);
// Remove last file association by pathconst updatedimagePaths = [ ...photoAlbum.imagePaths.filter((path) => path !== lastImagePath), newFilePath,];
// Update record with updated file associations:const response = await client.models.PhotoAlbum.update({ id: currentPhotoAlbum.id, imagePaths: updatedimagePaths,});
const updatedPhotoAlbum = response.data;
// If the record has no associated file, we can return early.if (!updatedPhotoAlbum?.imagePaths) return;
// Retrieve signed urls for merged image paths:const signedUrls = await Promise.all( updatedPhotoAlbum?.imagePaths.map( async (path) => await getUrl({ path: path! }) ));レコードのクエリと関連ファイルの取得
レコードに関連付けられたファイルを取得するには、まずレコードをクエリし、次にStorageを使用してすべての署名付きURLを取得します。
async function getImagesForPhotoAlbum() {import { generateClient } from "aws-amplify/api";import { uploadData, getUrl } from "aws-amplify/storage";import type { Schema } from "../amplify/data/resource";
// Generating the clientconst client = generateClient<Schema>({ authMode: "apiKey",});
// Query the record to get the file paths:const response = await client.models.PhotoAlbum.get({ id: currentPhotoAlbum.id,});
const photoAlbum = response.data;
// If the record has no associated files, we can return early.if (!photoAlbum?.imagePaths) return;
// Retrieve the signed URLs for the associated images:const signedUrls = await Promise.all( photoAlbum.imagePaths.map(async (imagePath) => { if (!imagePath) return; return await getUrl({ path: imagePath }); }));}APIレコードに関連するファイルの削除と除去
APIレコードに関連するファイルの削除と除去のワークフローは、単一ファイルを操作する場合と同じですが、削除を実行する際はファイルパスのリストを反復処理して各ファイルに対してStorage.remove()を呼び出す必要があります。
ファイルの関連付けを削除し、ファイルとレコードの両方を保持し続ける
import { generateClient } from "aws-amplify/api";import type { Schema } from "../amplify/data/resource";
// Generating the clientconst client = generateClient<Schema>({ authMode: "apiKey",});
const response = await client.models.PhotoAlbum.get({ id: currentPhotoAlbum.id,});
const photoAlbum = response.data;
// If the record has no associated file, we can return early.if (!photoAlbum?.imagePaths) return;
const updatedPhotoAlbum = await client.models.PhotoAlbum.update({ id: photoAlbum.id, imagePaths: null,});レコードの関連付けを削除してファイルを削除する
import { generateClient } from "aws-amplify/api";import { remove } from "aws-amplify/storage";import type { Schema } from "../amplify/data/resource";
// Generating the clientconst client = generateClient<Schema>({ authMode: "apiKey",});
const response = await client.models.PhotoAlbum.get({ id: currentPhotoAlbum.id,});
const photoAlbum = response.data;
// If the record has no associated files, we can return early.if (!photoAlbum?.imagePaths) return;
// Remove associated files from recordconst updateResponse = await client.models.PhotoAlbum.update({ id: photoAlbum.id, imagePaths: null, // Set the file association to `null`});
const updatedPhotoAlbum = updateResponse.data;
// Delete the files from S3:await Promise.all( photoAlbum?.imagePaths.map(async (imagePath) => { if (!imagePath) return; await remove({ path: imagePath }); }));レコードとすべての関連ファイルの削除
import { generateClient } from "aws-amplify/api";import { remove } from "aws-amplify/storage";import type { Schema } from "../amplify/data/resource";
// Generating the clientconst client = generateClient<Schema>({ authMode: "apiKey",});
const response = await client.models.PhotoAlbum.get({ id: currentPhotoAlbum.id,});
const photoAlbum = response.data;
if (!photoAlbum) return;
await client.models.PhotoAlbum.delete({ id: photoAlbum.id,});
setCurrentPhotoAlbum(null);
// If the record has no associated file, we can return early.if (!photoAlbum?.imagePaths) return;
await Promise.all( photoAlbum?.imagePaths.map(async (imagePath) => { if (!imagePath) return; await remove({ path: imagePath }); }));レコードとファイルを操作する際のデータ整合性
このドキュメントで推奨するアクセスパターンは、削除されたファイルを除去しようとしますが、存在しないファイルを参照するレコードを残すよりも、孤立したファイルを残す方を優先します。これにより、クライアントが存在しないStorageファイルを取得しようとすることが_ほとんどない_ことが保証され、読み取りレイテンシが最適化されます。ただし、ファイルを削除するアプリケーションでは、_デバイス上の_レコードが存在しないファイルを参照する可能性が本質的に生じます。
一例として、APIレコードを作成し、Storageファイルをそのレコードに関連付け、ファイルの署名付きURLを取得する場合があります。「デバイスA」がGraphQL APIを呼び出してAPI_Record_1を作成し、次にそのレコードをFirst_Photoと関連付けます。「デバイスA」が署名付きURLを取得しようとする直前に、「デバイスB」がAPI_Record_1をクエリし、First_Photoを削除して、それに応じてレコードを更新する可能性があります。しかし、「デバイスA」はまだ古いAPI_Record_1を使用しており、そのレコードはすでに存在しないファイルを参照しています。共有グローバル状態がすべての段階で正しく同期されているにもかかわらず、個々のデバイス(「デバイスA」)には存在しないファイルを参照する古いレコードが残っています。同様の問題が更新時にも発生する可能性があります。アプリによっては、リアルタイムデータ / GraphQLサブスクリプションを使用することで、これらの不一致を_さらに_最小化できる場合があります。
これらの不一致がいつ発生するかを理解し、そのようなケースに対して意味のあるエラーハンドリングを追加することが重要です。このガイドには、包括的なエラーハンドリング、リアルタイムサブスクリプション、古くなったレコードの再クエリ、失敗した操作の再試行は含まれていません。ただし、これらはすべて本番レベルのアプリケーションにとって重要な考慮事項です。