Importer et diffuser des images à l'aide de Cloud Storage, Firestore et Cloud Run

1. Introduction

Présentation

Dans cet atelier de programmation, vous allez apprendre à importer et à diffuser des images à l'aide de Cloud Storage, de Firestore et de Cloud Run. Vous apprendrez également à utiliser les bibliothèques clientes de Google pour l'authentification afin d'effectuer des appels à Gemini.

Points abordés

  • Déployer une application FastAPI sur Cloud Run
  • Utiliser les bibliothèques clientes de Google pour l'authentification
  • Importer un fichier dans Cloud Storage à l'aide d'un service Cloud Run
  • Lire et écrire des données dans Firestore
  • Récupérer et afficher des images depuis Cloud Storage dans un service Cloud Run

2. Préparation

Définissez les variables d'environnement qui seront utilisées tout au long de cet atelier de programmation.

PROJECT_ID=dogfood-gcf-saraford
REGION=us-central1
GCS_BUCKET_NAME=dogfood-gcf-saraford-codelab-wietse-2

SERVICE_NAME=fastapi-storage-firestore
SERVICE_ACCOUNT=fastapi-storage-firestore-sa
SERVICE_ACCOUNT_ADDRESS=$SERVICE_ACCOUNT@$PROJECT_ID.iam.gserviceaccount.com

Activer les API

gcloud services enable run.googleapis.com \
                       storage.googleapis.com \
                       firestore.googleapis.com \
                       cloudbuild.googleapis.com \
                       artifactregistry.googleapis.com

Créer un bucket Cloud Storage pour stocker les images

gsutil mb -p dogfood-gcf-saraford -l us-central1 gs://$GCS_BUCKET_NAME

Autorisez l'accès public au bucket dans lequel vous pouvez importer et afficher des images sur le site Web:

gsutil iam ch allUsers:objectViewer gs://$GCS_BUCKET_NAME

Créez un compte de service en exécutant la commande suivante:

gcloud iam service-accounts create $SERVICE_ACCOUNT \
    --display-name="SA for CR $SERVICE_ACCOUNT"

Accordez au compte de service l'accès à Firestore et au bucket GCS.

gcloud projects add-iam-policy-binding $PROJECT_ID \
    --member="serviceAccount:$SERVICE_ACCOUNT_ADDRESS" \
    --role="roles/datastore.user"

gsutil iam ch serviceAccount:$SERVICE_ACCOUNT_ADDRESS:roles/storage.objectAdmin gs://$GCS_BUCKET_NAME

3. Créer la base de données Firestore

Exécutez la commande suivante pour créer une base de données Firestore :

gcloud firestore databases create --location=nam5

4. Créer l'application

Créez un répertoire pour votre code.

mkdir codelab-cr-fastapi-firestore-gcs
cd codelab-cr-fastapi-firestore-gcs

Commencez par créer les modèles HTML en créant un répertoire de modèles.

mkdir templates
cd templates

Créez un fichier nommé index.html à l'aide du contenu suivant :

<!DOCTYPE html>
<html>
<head>
    <title>Cloud Run Image Upload Demo</title>
    <style>
        body { font-family: sans-serif; padding: 20px; }
        .upload-form { margin-bottom: 20px; padding: 15px; border: 1px solid #ccc; border-radius: 5px; background-color: #f9f9f9; }
        .image-list { margin-top: 30px; }
        .image-item { border-bottom: 1px solid #eee; padding: 10px 0; }
        .image-item img { max-width: 100px; max-height: 100px; vertical-align: middle; margin-right: 10px;}
        .error { color: red; font-weight: bold; margin-top: 10px;}
    </style>
</head>
<body>

    <h1>Upload an Image</h1>
    <p>Files will be uploaded to GCS bucket: <strong>{{ bucket_name }}</strong> and metadata stored in Firestore.</p>

    <div class="upload-form">
        <form action="/upload" method="post" enctype="multipart/form-data">
            <input type="file" name="file" accept="image/*" required>
            <button type="submit">Upload Image</button>
        </form>
        {% if error_message %}
            <p class="error">{{ error_message }}</p>
        {% endif %}
    </div>

    <div class="image-list">
        <h2>Recently Uploaded Images:</h2>
        {% if images %}
            {% for image in images %}
            <div class="image-item">
                <a href="{{ image.gcs_url }}" target="_blank">
                   <img src="{{ image.gcs_url }}" alt="{{ image.filename }}" title="Click to view full size">
                </a>
                <span>{{ image.filename }}</span>
                <small>(Uploaded: {{ image.uploaded_at.strftime('%Y-%m-%d %H:%M:%S') if image.uploaded_at else 'N/A' }})</small><br/>
                <small><a href="{{ image.gcs_url }}" target="_blank">{{ image.gcs_url }}</a></small>
            </div>
            {% endfor %}
        {% else %}
            <p>No images uploaded yet or unable to retrieve list.</p>
        {% endif %}
    </div>

</body>
</html>

Créez maintenant votre code Python et d'autres fichiers dans le répertoire racine.

cd ..

Créez un fichier .gcloudignore avec le contenu suivant:

__pycache__

Créez un fichier nommé main.py avec le contenu suivant :

import os
import datetime
from fastapi import FastAPI, File, UploadFile, Request, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from google.cloud import storage, firestore

# --- Configuration ---
# Get bucket name and firestore collection from Cloud Run env vars
GCS_BUCKET_NAME = os.environ.get("GCS_BUCKET_NAME", "YOUR_BUCKET_NAME_DEFAULT")
FIRESTORE_COLLECTION = os.environ.get("FIRESTORE_COLLECTION", "YOUR_FIRESTORE_DEFAULT")

# --- Initialize Google Client Libraries ---
# These client libraries will use the Application Default Credentials
# for your service account within the Cloud Run environment 
storage_client = storage.Client()
firestore_client = firestore.Client()

# --- FastAPI App ---
app = FastAPI()
templates = Jinja2Templates(directory="templates")

# --- Routes ---
@app.get("/", response_class=HTMLResponse)
async def read_root(request: Request):
    """Serves the main upload form."""
    
    # Query Firestore for existing images to display 
    images = []
    try:
        docs = firestore_client.collection(FIRESTORE_COLLECTION).order_by(
            "uploaded_at", direction=firestore.Query.DESCENDING
        ).limit(10).stream() # Get latest 10 images
        for doc in docs:
            images.append(doc.to_dict())
    except Exception as e:
        print(f"Warning: Could not fetch images from Firestore: {e}")
        # Continue without displaying images if Firestore query fails

    return templates.TemplateResponse("index.html", {
        "request": request,
        "bucket_name": GCS_BUCKET_NAME,
        "images": images # Pass images to the template
    })

@app.post("/upload")
async def handle_upload(request: Request, file: UploadFile = File(...)):
    """Handles file upload, saves to GCS, and records in Firestore."""
    if not file:
        return {"message": "No upload file sent"}
    elif not GCS_BUCKET_NAME or GCS_BUCKET_NAME == "YOUR_BUCKET_NAME_DEFAULT":
         return {"message": "GCS Bucket Name not configured."}, 500 # Internal Server Error

    try:
        # 1. Upload to GCS
        # note: to keep the demo code short, there are no file verifications
        # for an actual real-world production app, you will want to add checks
        gcs_url = upload_to_gcs(file, GCS_BUCKET_NAME)

        # 2. Save metadata to Firestore
        save_metadata_to_firestore(file.filename, gcs_url, FIRESTORE_COLLECTION)

        # Redirect back to the main page after successful upload
        return RedirectResponse(url="/", status_code=303) # Redirect using See Other

    except Exception as e:
        print(f"Upload failed: {e}")

        return templates.TemplateResponse("index.html", {
            "request": request,
            "bucket_name": GCS_BUCKET_NAME,
            "error_message": f"Upload failed: {e}",
            "images": [] # Pass empty list on error or re-query
        }, status_code=500)

# --- Helper Functions ---
def upload_to_gcs(uploadedFile: UploadFile, bucket_name: str) -> str:
    """Uploads a file to Google Cloud Storage and returns the public URL."""
    try:
        bucket = storage_client.bucket(bucket_name)

        # Create a unique blob name (e.g., timestamp + original filename)
        timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d%H%M%S")
        blob_name = f"{timestamp}_{uploadedFile.filename}"
        blob = bucket.blob(blob_name)

        # Upload the file
        # Reset file pointer just in case
        uploadedFile.file.seek(0)
        blob.upload_from_file(uploadedFile.file, content_type=uploadedFile.content_type)

        print(f"File {uploadedFile.filename} uploaded to gs://{bucket_name}/{blob_name}")
        return blob.public_url # Return the public URL

    except Exception as e:
        print(f"Error uploading to GCS: {e}")
        raise  # Re-raise the exception for FastAPI to handle

def save_metadata_to_firestore(filename: str, gcs_url: str, collection_name: str):
    """Saves image metadata to Firestore."""
    try:
        doc_ref = firestore_client.collection(collection_name).document()
        doc_ref.set({
            'filename': filename,
            'gcs_url': gcs_url,
            'uploaded_at': firestore.SERVER_TIMESTAMP # Use server timestamp
        })
        print(f"Metadata saved to Firestore collection {collection_name}")
    except Exception as e:
        print(f"Error saving metadata to Firestore: {e}")
        # Consider raising the exception or handling it appropriately
        raise # Re-raise the exception

Créez un fichier Dockerfile avec le contenu suivant:

# Build stage
FROM python:3.12-slim AS builder

WORKDIR /app

# Install poetry
RUN pip install poetry
RUN poetry self add poetry-plugin-export

# Copy poetry files
COPY pyproject.toml poetry.lock* ./

# Copy application code
COPY . .

# Export dependencies to requirements.txt
RUN poetry export -f requirements.txt --output requirements.txt 

# Final stage
FROM python:3.12-slim

WORKDIR /app

# Copy files from builder
COPY --from=builder /app/ .

# Install dependencies
RUN pip install --no-cache-dir -r requirements.txt

# Compile bytecode to improve startup latency
# -q: Quiet mode 
# -b: Write legacy bytecode files (.pyc) alongside source
# -f: Force rebuild even if timestamps are up-to-date
RUN python -m compileall -q -b -f .

# Expose port
EXPOSE 8080

# Run the application
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]

et vous avez créé un pyproject.toml avec les éléments suivants :

[tool.poetry]
name = "cloud-run-fastapi-demo"
version = "0.1.0"
description = "Demo FastAPI app for Cloud Run showing GCS upload and Firestore integration."
authors = ["Your Name <you@example.com>"]
readme = "README.md"

[tool.poetry.dependencies]
python = "^3.12"
fastapi = "^0.110.0"
uvicorn = {extras = ["standard"], version = "^0.29.0"} # Includes python-multipart
google-cloud-storage = "^2.16.0"
google-cloud-firestore = "^2.16.0"
jinja2 = "^3.1.3"
python-multipart = "^0.0.20"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

5. Déployer dans Cloud Run

Vous trouverez ci-dessous la commande à exécuter pour déployer dans Cloud Run. Votre code est compressé et envoyé à Cloud Build, qui utilise le Dockerfile pour créer l'image.

Comme il s'agit d'un déploiement basé sur le code source sur Cloud Run, un onglet "Source" contenant votre code s'affiche dans la console Cloud pour le service.

gcloud run deploy $SERVICE_NAME \
 --source . \
 --allow-unauthenticated \
 --service-account=$SERVICE_ACCOUNT_ADDRESS \
 --set-env-vars=GCS_BUCKET_NAME=$GCS_BUCKET_NAME \
 --set-env-vars=FIRESTORE_COLLECTION=$FIRESTORE_COLLECTION

6. Tester votre service

Ouvrez l'URL du service dans votre navigateur Web et importez une image. Il s'affichera dans la liste.

7. Modifier les autorisations du bucket Cloud Storage public

Comme indiqué précédemment, cet atelier de programmation utilise un bucket GCS public. Nous vous recommandons de supprimer le bucket ou de supprimer l'accès de tous les utilisateurs au bucket en exécutant la commande suivante:

gsutil iam ch -d allUsers:objectViewer gs://$GCS_BUCKET_NAME

Vous pouvez vérifier que l'accès "allUsers" a été supprimé en exécutant la commande suivante:

gsutil iam get gs://$GCS_BUCKET_NAME

8. Félicitations

Félicitations ! Vous avez terminé cet atelier de programmation.

Points abordés

  • Déployer une application FastAPI sur Cloud Run
  • Utiliser les bibliothèques clientes de Google pour l'authentification
  • Importer un fichier dans Cloud Storage à l'aide d'un service Cloud Run
  • Lire et écrire des données dans Firestore
  • Récupérer et afficher des images depuis Cloud Storage dans un service Cloud Run

9. Effectuer un nettoyage

Pour supprimer le service Cloud Run, accédez à la console Cloud Run à l'adresse https://console.cloud.google.com/run et supprimez le service.

Pour supprimer le bucket Cloud Storage, vous pouvez exécuter les commandes suivantes:

echo "Deleting objects in gs://$GCS_BUCKET_NAME..."
gsutil rm -r gs://$GCS_BUCKET_NAME/*

echo "Deleting bucket gs://$GCS_BUCKET_NAME..."
gsutil rb gs://$GCS_BUCKET_NAME

Si vous choisissez de supprimer l'ensemble du projet, accédez à https://console.cloud.google.com/cloud-resource-manager, sélectionnez le projet que vous avez créé à l'étape 2, puis choisissez "Supprimer". Si vous supprimez le projet, vous devrez modifier les projets dans votre SDK Cloud. Vous pouvez afficher la liste de tous les projets disponibles en exécutant gcloud projects list.