FastAPIで完璧なブログを構築:画像のアップロード
Daniel Hayes
Full-Stack Engineer · Leapcell

前の記事では、FastAPIブログにコメント返信機能を実装し、コメントセクションのインタラクティブ性を大幅に向上させました。
現在、投稿とコメントの機能はかなり充実していますが、投稿自体はプレーンテキストしかサポートしておらず、少し単調です。
この記事では、投稿に画像アップロード機能を追加し、ブログコンテンツをテキストと画像の両方でリッチにし、より表現力豊かにします。
画像アップロードを実装する原則は次のとおりです。
- ユーザーはフロントエンドページで画像を選択してアップロードします。
- バックエンドは画像を受信し、オブジェクトストレージサービスに保存します。
- バックエンドは画像の公開URLを返します。
- フロントエンドはこのURLをMarkdown形式(

)で投稿のコンテンツテキストボックスに挿入します。 - 投稿コンテンツが最終的にウェブページとしてレンダリングされると、ブラウザはこのURLを使用して画像を取得し、表示します。
ステップ1:S3互換オブジェクトストレージの準備
まず、ユーザーがアップロードした画像を保存する場所が必要です。サーバーのハードドライブに直接保存することもできますが、最新のWebアプリケーションでは、オブジェクトストレージサービス(AWS S3など)を使用することをお勧めします。メンテナンスが容易で、スケーラビリティが高く、コスト効率も良いためです。
利便性のために、データベースとバックエンドホスティングを提供するだけでなく、S3互換のオブジェクトストレージサービスも提供するLeapcellを引き続き使用します。
Leapcellのメインインターフェイスにログインし、「オブジェクトストレージの作成」をクリックします。
名前を入力してオブジェクトストレージを作成します。
オブジェクトストレージの詳細ページで、エンドポイント、アクセスキーID、シークレットアクセスキーなどの接続パラメータが表示されます。これらは後でバックエンド設定で使用します。
インターフェイスには、ブラウザで直接ファイルをアップロードおよび管理するための非常に便利なUIも用意されています。
ステップ2:バックエンドでの画像アップロードAPIの実装
次に、ファイルアップロードを処理するFastAPIバックエンドを構築しましょう。
1. 依存関係のインストール
S3互換オブジェクトストレージサービスにファイルをアップロードするには、boto3
(Python用のAWS SDK)が必要です。さらに、投稿を表示する際にMarkdown形式をHTMLに変換するために、Markdown解析ライブラリが必要です。ここではmarkdown2
を選択します。
これらをrequirements.txt
ファイルに追加します。
# requirements.txt # ... 他のパッケージ boto3 markdown2
次に、インストールコマンドを実行します。
pip install -r requirements.txt
2. アップロードサービスの作成
コードをクリーンに保つために、ファイルアップロード機能用の新しいサービスファイルを作成します。
プロジェクトのルートディレクトリに新しいファイルuploads_service.py
を作成します。このサービスは、S3との通信のコアロジックを担当します。
# uploads_service.py import boto3 import uuid from fastapi import UploadFile # --- S3 設定 --- # これらの値は環境変数から読み込むことをお勧めします S3_ENDPOINT_URL = "https://objstorage.leapcell.io" S3_ACCESS_KEY_ID = "YOUR_ACCESS_KEY_ID" S3_SECRET_ACCESS_KEY = "YOUR_SECRET_ACCESS_KEY" S3_BUCKET_NAME = "my-fastapi-blog-images" # バケット名 S3_PUBLIC_URL = f"{S3_BUCKET_NAME}.leapcellobj.com" # バケットの公開アクセスURL # S3クライアントの初期化 s3_client = boto3.client( "s3", endpoint_url=S3_ENDPOINT_URL, aws_access_key_id=S3_ACCESS_KEY_ID, aws_secret_access_key=S3_SECRET_ACCESS_KEY, region_name="us-east-1", # S3互換ストレージの場合、リージョンはしばしば名目上のものです ) def upload_file_to_s3(file: UploadFile) -> str: """ ファイルをS3にアップロードし、公開URLを返します。 """ try: # 競合を避けるために一意のファイル名を生成します file_extension = file.filename.split(".")[-1] unique_filename = f"{uuid.uuid4()}.{file_extension}" s3_client.upload_fileobj( file.file, # A file-like object S3_BUCKET_NAME, unique_filename, ExtraArgs={ "ContentType": file.content_type, "ACL": "public-read", # ファイルを公開読み取り可能に設定します }, ) # ファイルの公開URLを返します return f"{S3_PUBLIC_URL}/{unique_filename}" except Exception as e: print(f"S3へのアップロードエラー: {e}") raise
注意: 実装を簡略化するために、S3接続パラメータはハードコーディングされています。実際のプロジェクトでは、この機密情報を環境変数に保存し、os.getenv()
を使用して読み込むことを強くお勧めします。
3. アップロードルートの作成
次に、APIルートを定義するために、routers
フォルダに新しいファイルuploads.py
を作成しましょう。
# routers/uploads.py from fastapi import APIRouter, Depends, UploadFile, File from auth_dependencies import login_required import uploads_service router = APIRouter() @router.post("/uploads/image") def upload_image( user: dict = Depends(login_required), # ログインユーザーのみアップロード可能 file: UploadFile = File(...) ): """ アップロードされた画像ファイルを受信し、S3にアップロードしてURLを返します。 """ url = uploads_service.upload_file_to_s3(file) return {"url": url}
最後に、この新しいルーターモジュールをメインアプリケーションmain.py
にマウントします。
# main.py # ... 他のインポート from routers import posts, users, auth, comments, uploads # uploadsルーターをインポート # ... app = FastAPI(lifespan=lifespan) # ... # ルーターを含める app.include_router(posts.router) app.include_router(users.router) app.include_router(auth.router) app.include_router(comments.router) app.include_router(uploads.router) # uploadsルーターをマウント
ステップ3:フロントエンドでのFilePicker APIの統合
バックエンドの準備ができたので、new-post.html
フロントエンドページを変更してアップロード機能を追加しましょう。
アップロードを処理するには、モダンなFilePicker APIと従来の<input type="file">
の2つの方法があります。
従来のメソッド:<input type="file">
は優れた互換性を持ち、すべてのブラウザでサポートされています。ただし、APIはやや時代遅れで、直感的ではなく、ユーザーエクスペリエンスが悪いです。
モダンなメソッド:File System Access APIは使いやすく、強力で、より良いユーザーエクスペリエンスにつながります。ただし、互換性は従来のメソッドほどではなく、セキュアなコンテキスト(HTTPS)で実行する必要があります。
私たちのブログはモダンなプロジェクトであるため、FilePicker APIを使用してファイルアップロードを実装します。
templates/new-post.html
を開き、textarea
の上にツールバーと「画像のアップロード」ボタンを追加します。
{% include "_header.html" %} <form action="/posts" method="POST" class="post-form"> <div class="form-group"> <label for="title">タイトル</label> <input type="text" id="title" name="title" required /> </div> <div class="form-group"> <label for="content">コンテンツ</label> <div class="toolbar"> <button type="button" id="upload-image-btn">画像のアップロード</button> </div> <textarea id="content" name="content" rows="10" required></textarea> </div> <button type="submit">送信</button> </form> <script> document.addEventListener("DOMContentLoaded", () => { const uploadBtn = document.getElementById("upload-image-btn"); const contentTextarea = document.getElementById("content"); uploadBtn.addEventListener("click", async () => { try { const [fileHandle] = await window.showOpenFilePicker({ types: [ { description: "Images", accept: { "image/*": [".png", ".jpeg", ".jpg", ".gif", ".webp"] }, }, ], }); const file = await fileHandle.getFile(); uploadFile(file); } catch (error) { // AbortError はユーザーがファイル選択をキャンセルした場合にスローされます。これは無視します。 if (error.name !== "AbortError") { console.error("FilePicker Error:", error); } } }); function uploadFile(file) { if (!file) return; const formData = new FormData(); formData.append("file", file); // 簡単なローディングインジケーターを表示します uploadBtn.disabled = true; uploadBtn.innerText = "アップロード中..."; fetch("/uploads/image", { method: "POST", body: formData, // 注意:FormData を使用する場合、Content-Type ヘッダーを手動で設定する必要はありません }) .then((response) => response.json()) .then((data) => { if (data.url) { // 返された画像URLをMarkdown形式でテキストボックスに挿入します const markdownImage = ``; insertAtCursor(contentTextarea, markdownImage); } else { alert("アップロードに失敗しました。もう一度お試しください。"); } }) .catch((error) => { console.error("Upload Error:", error); alert("アップロード中にエラーが発生しました。"); }) .finally(() => { uploadBtn.disabled = false; uploadBtn.innerText = "画像のアップロード"; }); } // カーソル位置にテキストを挿入するヘルパー関数 function insertAtCursor(myField, myValue) { if (myField.selectionStart || myField.selectionStart === 0) { var startPos = myField.selectionStart; var endPos = myField.selectionEnd; myField.value = myField.value.substring(0, startPos) + myValue + myField.value.substring(endPos, myField.value.length); myField.selectionStart = startPos + myValue.length; myField.selectionEnd = startPos + myValue.length; } else { myField.value += myValue; } } }); </script> {% include "_footer.html" %}
ステップ4:画像を含む投稿のレンダリング
画像へのMarkdownリンクを投稿コンテンツに挿入できましたが、まだテキスト文字列としてレンダリングされるだけです。投稿詳細ページpost.html
で、Markdown形式を実際のHTMLに変換する必要があります。
1. ルートでのMarkdown解析
routers/posts.py
のget_post_by_id
関数を変更します。投稿データをテンプレートに渡す前に、markdown2
でコンテンツを解析します。
# routers/posts.py # ... 他のインポート import markdown2 # markdown2をインポート # ... @router.get("/posts/{post_id}", response_class=HTMLResponse) def get_post_by_id( request: Request, post_id: uuid.UUID, session: Session = Depends(get_session), user: dict | None = Depends(get_user_from_session), ): post = session.get(Post, post_id) comments = comments_service.get_comments_by_post_id(post_id, session) # Markdownコンテンツを解析します if post: post.content = markdown2.markdown(post.content) return templates.TemplateResponse( "post.html", { "request": request, "post": post, "title": post.title, "user": user, "comments": comments, }, )
2. 投稿詳細ページビューの更新
最後に、templates/post.html
を変更して、解析されたHTMLが正しくレンダリングされるようにします。以前は、改行を処理するために{{ post.content | replace(' ', '<br>') | safe }}
を使用していました。コンテンツがすでにHTMLになっているため、safe
フィルターを使用するだけで済みます。
{# templates/post.html #} {# ... #} <article class="post-detail"> <h1>{{ post.title }}</h1> <small>{{ post.createdAt.strftime('%Y-%m-%d') }}</small> <div class="post-content">{{ post.content | safe }}</div> </article> {# ... #}
safe
フィルターは、Jinja2にこの変数のコンテンツが安全であり、HTMLエスケープする必要がないことを伝えます。これにより、画像<img>
タグやその他のMarkdownフォーマットが正しくレンダリングされます。
実行とテスト
これで、アプリケーションを再起動します。
uvicorn main:app --reload
ログイン後、「新規投稿」ページに移動すると、新しい「画像のアップロード」ボタンが表示されます。それをクリックしてアップロードするファイルを選択します。
画像を選択します。アップロードが完了すると、画像のMarkdownリンクがテキストボックスに自動的に挿入されます。
投稿を発行し、投稿詳細ページに移動します。画像が正常にレンダリングされていることがわかります。さらに、投稿コンテンツはMarkdown構文をサポートするようになりました!
おめでとうございます。ブログで画像(およびMarkdown)のアップロードがサポートされるようになりました!これからは、あなたのブログは間違いなくはるかにエキサイティングになるでしょう。