DEV Community

Cover image for Build a Perfect Blog with FastAPI: Upload Image
Leapcell
Leapcell

Posted on

Build a Perfect Blog with FastAPI: Upload Image

In the previous article, we implemented a comment reply feature for our FastAPI blog, significantly enhancing the interactivity of the comments section.

Now, while the functionality for posts and comments is quite complete, the posts themselves only support plain text, which is a bit monotonous.

In this article, we will add an image upload feature to the posts, allowing your blog content to be rich with both text and images, making it more expressive.

The principle behind implementing image uploads is as follows:

  1. A user selects an image on the frontend page and uploads it.
  2. The backend receives the image and stores it in an object storage service.
  3. The backend returns a publicly accessible URL for the image.
  4. The frontend inserts this URL into the post's content text box in Markdown format (![alt](URL)).
  5. When the post content is finally rendered as a webpage, the browser fetches the image using this URL and displays it.

Step 1: Prepare S3-Compatible Object Storage

First, we need a place to store the images uploaded by users. Although you could store them directly on the server's hard drive, in modern web applications, it's more recommended to use an object storage service (like AWS S3) because it's easier to maintain, more scalable, and more cost-effective.

For convenience, we will continue to use Leapcell, which not only provides a database and backend hosting but also offers an S3-compatible Object Storage service.

Leapcell

Log in to the Leapcell main interface and click "Create Object Storage".

Create Object Storage

Fill in a name to create the Object Storage.

Object Storage P1

On the Object Storage details page, you can see the connection parameters like Endpoint, Access Key ID, and Secret Access Key. We will use these in our backend configuration later.

Object Storage P2

The interface also provides a very convenient UI for uploading and managing files directly in the browser.

Object Storage P3

Step 2: Implement the Image Upload API on the Backend

Next, let's build the FastAPI backend to handle file uploads.

1. Install Dependencies

We need boto3 (the AWS SDK for Python) to upload files to an S3-compatible object storage service. Additionally, we need a Markdown parsing library to convert Markdown format to HTML when displaying posts. Here, we'll choose markdown2.

Add them to your requirements.txt file:

# requirements.txt
# ... other packages
boto3
markdown2
Enter fullscreen mode Exit fullscreen mode

Then run the installation command:

pip install -r requirements.txt
Enter fullscreen mode Exit fullscreen mode

2. Create an Upload Service

To keep our code clean, we'll create a new service file for the file upload functionality.

Create a new file uploads_service.py in the project's root directory. This service will be responsible for the core logic of communicating with S3.

# uploads_service.py
import boto3
import uuid
from fastapi import UploadFile

# --- S3 Configuration ---
# It is recommended to read these values from environment variables
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" # Your Bucket name
S3_PUBLIC_URL = f"https://{S3_BUCKET_NAME}.leapcellobj.com" # Your Bucket's public access URL

# Initialize S3 client
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", # For S3-compatible storage, the region is often nominal
)

def upload_file_to_s3(file: UploadFile) -> str:
    """
    Uploads a file to S3 and returns its public URL.
    """
    try:
        # Generate a unique filename to avoid conflicts
        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",  # Set the file to be publicly readable
            },
        )

        # Return the public URL of the file
        return f"{S3_PUBLIC_URL}/{unique_filename}"
    except Exception as e:
        print(f"Error uploading to S3: {e}")
        raise
Enter fullscreen mode Exit fullscreen mode

Note: To simplify the implementation, the S3 connection parameters are hardcoded. In a real project, it is strongly recommended to store this sensitive information in environment variables and read it using os.getenv().

3. Create an Upload Route

Now, let's create a new file uploads.py in the routers folder to define the API route.

# 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), # Only logged-in users can upload
    file: UploadFile = File(...)
):
    """
    Receives an uploaded image file, uploads it to S3, and returns the URL.
    """
    url = uploads_service.upload_file_to_s3(file)
    return {"url": url}
Enter fullscreen mode Exit fullscreen mode

Finally, mount this new router module in the main application main.py.

# main.py
# ... other imports
from routers import posts, users, auth, comments, uploads # import the uploads router

# ...

app = FastAPI(lifespan=lifespan)

# ...

# Include routers
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) # mount the uploads router
Enter fullscreen mode Exit fullscreen mode

Step 3: Integrate the FilePicker API on the Frontend

With the backend ready, let's modify the new-post.html frontend page to add the upload functionality.

There are two ways to handle uploads: the modern FilePicker API and the traditional <input type="file">.

Traditional method: <input type="file"> has excellent compatibility and is supported by all browsers. However, its API is somewhat outdated, not very intuitive, and provides a poor user experience.

Modern method: The File System Access API is easier to use, more powerful, and can lead to a better user experience. However, its compatibility is not as good as the traditional method, and it must be run in a secure context (HTTPS).

Considering our blog is a modern project, we will use the FilePicker API to implement file uploads.

Open templates/new-post.html and add a toolbar and an "Upload Image" button above the textarea.

{% include "_header.html" %}
<form action="/posts" method="POST" class="post-form">
  <div class="form-group">
    <label for="title">Title</label>
    <input type="text" id="title" name="title" required />
  </div>
  <div class="form-group">
    <label for="content">Content</label>
    <div class="toolbar">
      <button type="button" id="upload-image-btn">Upload Image</button>
    </div>
    <textarea id="content" name="content" rows="10" required></textarea>
  </div>
  <button type="submit">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) {
        // An AbortError is thrown when the user cancels file selection, we ignore it
        if (error.name !== "AbortError") {
          console.error("FilePicker Error:", error);
        }
      }
    });

    function uploadFile(file) {
      if (!file) return;

      const formData = new FormData();
      formData.append("file", file);

      // Display a simple loading indicator
      uploadBtn.disabled = true;
      uploadBtn.innerText = "Uploading...";

      fetch("/uploads/image", {
        method: "POST",
        body: formData,
        // Note: You don't need to manually set the Content-Type header when using FormData
      })
        .then((response) => response.json())
        .then((data) => {
          if (data.url) {
            // Insert the returned image URL into the textbox in Markdown format
            const markdownImage = `![${file.name}](${data.url})`;
            insertAtCursor(contentTextarea, markdownImage);
          } else {
            alert("Upload failed. Please try again.");
          }
        })
        .catch((error) => {
          console.error("Upload Error:", error);
          alert("An error occurred during upload.");
        })
        .finally(() => {
          uploadBtn.disabled = false;
          uploadBtn.innerText = "Upload Image";
        });
    }

    // Helper function to insert text at the cursor position
    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" %}
Enter fullscreen mode Exit fullscreen mode

Step 4: Render Posts Containing Images

We have successfully inserted the Markdown link for the image into the post's content, but it still just renders as a string of text. We need to convert the Markdown format into actual HTML on the post detail page post.html.

1. Parse Markdown in the Route

Modify the get_post_by_id function in routers/posts.py. Before passing the post data to the template, parse its content with markdown2.

# routers/posts.py
# ... other imports
import markdown2 # import 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)

    # Parse Markdown content
    if post:
        post.content = markdown2.markdown(post.content)

    return templates.TemplateResponse(
        "post.html",
        {
            "request": request,
            "post": post,
            "title": post.title,
            "user": user,
            "comments": comments,
        },
    )
Enter fullscreen mode Exit fullscreen mode

2. Update the Post Detail Page View

Finally, modify templates/post.html to ensure it correctly renders the parsed HTML. Previously, we used {{ post.content | replace('\n', '<br>') | safe }} to handle line breaks. Now, since the content is already HTML, we just need to use the safe filter.

{# 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>
{# ... #}
Enter fullscreen mode Exit fullscreen mode

The safe filter tells Jinja2 that the content of this variable is safe and does not need HTML escaping, which allows the image <img> tag and other Markdown formatting to be rendered correctly.

Run and Test

Now, restart your application:

uvicorn main:app --reload
Enter fullscreen mode Exit fullscreen mode

After logging in, go to the "New Post" page, and you will see the new "Upload Image" button. Click it to select a file for upload.

Select an image. After the upload is complete, the Markdown link for the image will be automatically inserted into the text box.

ImageP1

Publish the post and go to the post detail page. You will see that the image is successfully rendered. And as an added bonus, the post content now supports Markdown syntax!

ImageP2

Congratulations, your blog now supports image uploads (and Markdown)! From now on, your blog will surely be much more exciting.


Follow us on X: @LeapcellHQ


Read on our blog

Related Posts:

Top comments (0)