Tutorial Discovery and Review API
Build a REST API with FastAPI and Gemini AI to auto-enrich tutorials.
Introduction
30 Second Summary
Every developer has written a tutorial that disappears into the void. No tags, no ratings, no way for anyone to find it unless they already know the exact title.
In this project, you will build a backend API that accepts developer tutorials and uses Google Gemini AI to automatically generate polished metadata for each one. You will also add a rating and review system so the best content rises to the top.
What You'll Build
You will submit a rough tutorial about "Setting up a Python virtual environment," open your interactive Swagger docs, and watch the AI transform it into a discoverable card with skill tags, a difficulty level, and a time estimate. Then you will rate it and see it climb the leaderboard.
By the end of this project, you'll have:
- A working REST API that accepts tutorial submissions and instantly returns AI-generated metadata including a summary, skill tags, difficulty level, and time estimate.
- A persistent database where you can stop the server, restart it, and see all your tutorials still there with interactive Swagger documentation to explore every endpoint.
- A review and rating system where you post scores, watch averages update in real time, and see the highest-rated tutorials surface first.
- Secret Mission: Add a search endpoint that lets users find tutorials by keyword across titles, summaries, and skill tags.
Are there any prerequisites?
You should be comfortable with basic Python concepts like functions, dictionaries, and lists. No prior experience with APIs or databases is needed. Step 1 walks you through installing everything from scratch.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
This is your moment to commit to the goal before any hands-on work begins.
Set Up Your Project
Every API project starts with a foundation: a folder, the right libraries, and credentials to connect to external services.
In this step, you will create your project folder, install FastAPI, SQLAlchemy, and the Google Gemini SDK, then verify the whole stack works with a minimal API endpoint.
In this step, get ready to:
- Create your project folder and install dependencies.
- Get a free Gemini API key from Google AI Studio.
- Verify your setup with a running FastAPI server.
Create your project folder and virtual environment
A virtual environment keeps your project's packages isolated from the rest of your system.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- Open a terminal inside VS Code by clicking Terminal in the top menu bar, then New Terminal.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir tutorial-api
cd tutorial-api
- Confirm the folder exists by running:
ls
The folder is empty for now, but you should see no errors.
- Open this folder in VS Code by clicking File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the tutorial-api folder, and click Open.
Why open the folder in VS Code?
Opening the folder sets VS Code's workspace root to tutorial-api. Every new terminal you open in VS Code will automatically start inside this folder, so you never have to cd back to it.
- Open a fresh terminal in VS Code by clicking Terminal in the top menu bar, then New Terminal.
- Create a Python virtual environment by running:
python -m venv venv
- Activate the virtual environment by running:
macOS
source venv/bin/activate
Windows
venv\Scripts\activate
You should see (venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active.
Don't see (venv) in your prompt?
On Windows, if you see an error about execution policies, run Set-ExecutionPolicy -Scope CurrentUser RemoteSigned and try the activate command again.
On macOS, make sure you typed source before the path. Without it, the script runs in a subshell and the activation disappears.
Help me activate my Python virtual environment.
Install your project dependencies
Your project needs three libraries: FastAPI for the web framework, SQLAlchemy for the database, and google-genai for AI integration.
- Create a new file in VS Code by clicking File in the top menu bar, then New File.
- Name the file requirements.txt and press Enter.
- Paste the following content into requirements.txt:
fastapi[standard]==0.139.0
sqlalchemy==2.0.51
google-genai==2.11.0
What does this file do?
- fastapi[standard]==0.139.0 installs FastAPI plus its recommended dependencies (including the Uvicorn server).
- sqlalchemy==2.0.51 installs the ORM you will use to talk to the database.
- google-genai==2.11.0 installs the official Google Gemini SDK.
- Save requirements.txt by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- Install all three packages by running this command in your terminal:
pip install -r requirements.txt
Pip will download and install each package. You should see a line near the end that says Successfully installed followed by the package names.
Seeing an error during install?
Make sure (venv) is visible in your prompt. If it is not, re-run the activate command from the previous substep.
If you see a network timeout, check your internet connection and try the command again.
Help me fix my pip install error.
Get a Gemini API key
The Gemini API is free to use on Google's standard tier. You need an API key so your app can authenticate with Gemini when it generates metadata for tutorials.
- Open your browser and go to aistudio.google.com.
- Sign in with your Google account.
- Click Get API key in the left sidebar (or navigate to the API keys page).
What if I already see an API key?
Google AI Studio automatically creates a project and API key for new users. If you already see a key listed on the API keys page, you can use that one. Otherwise, click Create API key to generate a new one.
- Copy your API key from the API keys page.
- Back in your VS Code terminal, set the API key as an environment variable by running:
macOS
export GEMINI_API_KEY=your-api-key-here
Windows
set GEMINI_API_KEY=your-api-key-here
- Replace your-api-key-here with the key you just copied. Do not add quotes around the key.
Why an environment variable?
Storing the key in an environment variable keeps it out of your source code. The Gemini SDK automatically detects GEMINI_API_KEY and uses it for authentication, so you never need to paste the key into your Python files.
Having trouble getting a key?
Make sure you are signed in with a personal Google account. Some work or school accounts have restrictions that prevent API key creation.
If the page does not load, try opening it in an incognito window or a different browser.
Help me get a Gemini API key.
Create a minimal FastAPI app and verify your setup
Now that your environment is ready, you will create a single-endpoint API to confirm FastAPI, Python, and your terminal are all working together.
- Create a new file in VS Code by clicking File in the top menu bar, then New File.
- Name the file main.py and press Enter.
- Paste the following code into main.py:
from fastapi import FastAPI
# Create the FastAPI application instance
app = FastAPI(title="Tutorial Discovery API")
# A simple health-check endpoint
@app.get("/")
def root():
return {"message": "Tutorial Discovery API is running"}
What does this code do?
- FastAPI() creates your app. The title argument shows up in the auto-generated API documentation.
- @app.get("/") registers a GET endpoint at the root URL. When someone visits /, FastAPI calls root() and returns the dictionary as JSON.
- Save main.py by pressing Cmd+S (macOS) or Ctrl+S (Windows).
Before you run the server, what do you expect to see in the terminal output?
- Start the FastAPI development server by running:
fastapi dev main.py
What does this command do?
This starts a local development server with auto-reload. Any time you save a change to your code, the server restarts automatically. The command takes over this terminal window, so you will need to open a new terminal for other commands later.
You should see output that includes Uvicorn running on http://127.0.0.1:8000. That means your API is live.
- Open your browser and navigate to http://127.0.0.1:8000.
You should see {"message":"Tutorial Discovery API is running"} displayed in the browser.
- Now navigate to http://127.0.0.1:8000/docs in your browser.
This is Swagger UI. FastAPI auto-generates interactive documentation for every endpoint you create. You will use this page throughout the project to test your API.
Don't see the JSON response?
Check your terminal for error messages. A common cause is a typo in main.py. Make sure the file is saved and matches the code above exactly.
If you see ModuleNotFoundError: No module named 'fastapi', your virtual environment is not active. Run the activate command again, then re-run fastapi dev main.py.
Help me get my FastAPI server running.
✔️ Awesome, I've got everything!
Great. Double check that you have saved both files.
ⓧ I'd like to double check the full code
Here is the complete requirements.txt:
fastapi[standard]==0.139.0
sqlalchemy==2.0.51
google-genai==2.11.0
Here is the complete main.py:
from fastapi import FastAPI
app = FastAPI(title="Tutorial Discovery API")
@app.get("/")
def root():
return {"message": "Tutorial Discovery API is running"}
Your environment is ready and your API is live. Next up, you will build the tutorial submission endpoint so users can start posting content.
Build the Tutorial Submission Endpoint
Your API is running, but it can't actually do anything useful yet. Before adding AI enrichment or a database, you need the basic skeleton of the product: an endpoint that accepts a tutorial and one that retrieves it.
In this step, you'll define the data shapes (schemas) that control what goes into and comes out of your API. Then you'll wire up three endpoints using a simple in-memory list so you can immediately submit and retrieve tutorials.
In this step, get ready to:
- Define Pydantic schemas for tutorial input and output.
- Build POST and GET endpoints using an in-memory list.
- Test the full submit-and-retrieve flow in Swagger UI.
Define Pydantic schemas for tutorial input and output
Every FastAPI endpoint needs to know the shape of the data it receives and returns. Pydantic models define those shapes and automatically validate incoming requests for you.
- Create a new file called schemas.py in your tutorial-api folder (at the same level as main.py).
- Add the tutorial schemas by pasting this code:
from datetime import datetime
from pydantic import BaseModel, Field
# Schema for data the user submits when creating a tutorial
class TutorialCreate(BaseModel):
title: str
content: str
author: str
# Schema for data returned to the user after creation
class TutorialResponse(BaseModel):
id: int
title: str
content: str
author: str
summary: str
what_you_will_learn: str
skills: str
difficulty: str
estimated_minutes: int
average_rating: float = 0.0
review_count: int = 0
created_at: datetime
class Config:
from_attributes = True
What does this code do?
- TutorialCreate defines what a user must provide: a title, content, and author name.
- TutorialResponse defines everything the API sends back, including AI-generated fields (summary, skills, difficulty) that start empty and get filled in later.
- from_attributes = True lets Pydantic read data from database objects later when you add persistence.
- Add the review schemas below the TutorialResponse class:
# Schema for submitting a review (rating 1-5 with optional comment)
class ReviewCreate(BaseModel):
rating: int = Field(ge=1, le=5)
comment: str = ""
# Schema for review data returned by the API
class ReviewResponse(BaseModel):
id: int
tutorial_id: int
rating: int
comment: str
created_at: datetime
class Config:
from_attributes = True
Why add review schemas now?
You won't use ReviewCreate and ReviewResponse until a later step. Defining all schemas upfront keeps them in one file so you only create schemas.py once.
Field(ge=1, le=5) means the rating must be between 1 and 5. FastAPI will reject anything outside that range automatically.
- Save schemas.py.
Seeing a red underline in VS Code?
If your editor shows an import error on BaseModel or Field, confirm your virtual environment is active (you should see (venv) in your terminal prompt). VS Code sometimes needs a reload to pick up newly installed packages.
help me fix import errors in my schemas file
Build the POST and GET endpoints
Now you'll update main.py to import your schemas and add three endpoints that store tutorials in a simple Python list.
- Replace the entire contents of main.py with this code:
from fastapi import FastAPI, HTTPException
from datetime import datetime, timezone
from schemas import TutorialCreate, TutorialResponse
app = FastAPI(title="Tutorial Discovery API")
# In-memory storage (replaced with a database in a later step)
tutorials_db = []
next_id = 1
@app.get("/")
def root():
return {"message": "Tutorial Discovery API is running"}
What changed here?
- HTTPException is imported so you can return proper 404 errors when a tutorial isn't found.
- TutorialCreate and TutorialResponse are imported from your new schemas.py file.
- tutorials_db is a plain list that temporarily holds tutorials in memory. next_id tracks the auto-incrementing ID for each new tutorial.
- Save main.py and start the dev server by running this command in your terminal:
fastapi dev main.py
You should see Uvicorn running on http://127.0.0.1:8000 in the terminal output. This confirms your schemas import correctly and the server can start.
Seeing ModuleNotFoundError: No module named 'schemas'?
Make sure schemas.py is saved in the same folder as main.py (both should be directly inside tutorial-api). Also confirm you're running the command from the tutorial-api folder, not from inside venv.
help me fix my schemas import error
- Add the POST /tutorials endpoint below the root() function in main.py:
@app.post("/tutorials", response_model=TutorialResponse)
def create_tutorial(tutorial_data: TutorialCreate):
global next_id
# Build a response object with user data and empty AI fields
tutorial = TutorialResponse(
id=next_id,
title=tutorial_data.title,
content=tutorial_data.content,
author=tutorial_data.author,
summary="",
what_you_will_learn="",
skills="",
difficulty="",
estimated_minutes=0,
average_rating=0.0,
review_count=0,
created_at=datetime.now(timezone.utc),
)
tutorials_db.append(tutorial)
next_id += 1
return tutorial
What does this endpoint do?
When someone POSTs to /tutorials, FastAPI automatically validates the request body against TutorialCreate (title, content, author must all be present).
The handler builds a TutorialResponse with AI fields set to empty strings for now. A later step will fill those in with Gemini. The tutorial gets appended to the in-memory list and returned to the caller.
- Add the two GET endpoints below the create_tutorial() function:
@app.get("/tutorials", response_model=list[TutorialResponse])
def list_tutorials():
return tutorials_db
@app.get("/tutorials/{tutorial_id}", response_model=TutorialResponse)
def get_tutorial(tutorial_id: int):
# Search the list for a matching ID
for t in tutorials_db:
if t.id == tutorial_id:
return t
raise HTTPException(status_code=404, detail="Tutorial not found")
What do these endpoints do?
- GET /tutorials returns every tutorial in the list.
- GET /tutorials/{tutorial_id} looks up a single tutorial by its ID. If no match is found, it raises an HTTPException with a 404 status code.
- Save main.py.
- Your terminal should show the server auto-reloading (FastAPI's dev mode detects file changes and restarts automatically).
Server crashed on reload?
Check for indentation errors. Each endpoint function must be at the top level (no extra indentation). Also confirm you placed the new endpoints after the root() function, not inside it.
help me fix my main.py after adding endpoints
✔️ Awesome, I've got everything!
Great. Double check you've saved both schemas.py and main.py, and that your server is running without errors.
ⓧ I'd like to double check the full code
Here's the complete schemas.py:
from datetime import datetime
from pydantic import BaseModel, Field
class TutorialCreate(BaseModel):
title: str
content: str
author: str
class TutorialResponse(BaseModel):
id: int
title: str
content: str
author: str
summary: str
what_you_will_learn: str
skills: str
difficulty: str
estimated_minutes: int
average_rating: float = 0.0
review_count: int = 0
created_at: datetime
class Config:
from_attributes = True
And the second half of schemas.py (place directly below TutorialResponse):
class ReviewCreate(BaseModel):
rating: int = Field(ge=1, le=5)
comment: str = ""
class ReviewResponse(BaseModel):
id: int
tutorial_id: int
rating: int
comment: str
created_at: datetime
class Config:
from_attributes = True
Here's the complete main.py:
from fastapi import FastAPI, HTTPException
from datetime import datetime, timezone
from schemas import TutorialCreate, TutorialResponse
app = FastAPI(title="Tutorial Discovery API")
tutorials_db = []
next_id = 1
@app.get("/")
def root():
return {"message": "Tutorial Discovery API is running"}
The POST and GET endpoints in main.py (below the root function):
@app.post("/tutorials", response_model=TutorialResponse)
def create_tutorial(tutorial_data: TutorialCreate):
global next_id
tutorial = TutorialResponse(
id=next_id,
title=tutorial_data.title,
content=tutorial_data.content,
author=tutorial_data.author,
summary="",
what_you_will_learn="",
skills="",
difficulty="",
estimated_minutes=0,
average_rating=0.0,
review_count=0,
created_at=datetime.now(timezone.utc),
)
tutorials_db.append(tutorial)
next_id += 1
return tutorial
@app.get("/tutorials", response_model=list[TutorialResponse])
def list_tutorials():
return tutorials_db
@app.get("/tutorials/{tutorial_id}", response_model=TutorialResponse)
def get_tutorial(tutorial_id: int):
for t in tutorials_db:
if t.id == tutorial_id:
return t
raise HTTPException(status_code=404, detail="Tutorial not found")
Test the submit-and-retrieve flow in Swagger UI
FastAPI auto-generates interactive documentation where you can test every endpoint without writing any client code.
- Open http://127.0.0.1:8000/docs in your browser.
- You should see three new endpoints listed: POST /tutorials, GET /tutorials, and GET /tutorials/{tutorial_id}.
Before you submit a tutorial, what do you think the AI fields (summary, skills, difficulty) will contain in the response?
- Click on POST /tutorials to expand it.
- Click Try it out.
- Replace the example JSON in the request body with this:
{
"title": "Setting up a Python virtual environment",
"content": "Virtual environments isolate your project dependencies from your system Python. Run python -m venv venv to create one, then activate it with source venv/bin/activate on macOS or venv\\Scripts\\activate on Windows.",
"author": "NextWork Dev"
}
- Click Execute.
The response shows your tutorial with an auto-assigned id of 1, empty AI fields (as expected), and a created_at timestamp.
- Click on GET /tutorials, then Try it out, then Execute.
You should see a list containing your tutorial. The list has one item matching what you just submitted.
- Go back to POST /tutorials and submit a second tutorial with a different title and content.
- Run GET /tutorials again and confirm both tutorials appear in the response.
- Try GET /tutorials/{tutorial_id} with tutorial_id set to 999.
You should see a 404 response with "detail": "Tutorial not found". Your API correctly rejects requests for tutorials that don't exist.
Not seeing the new endpoints in Swagger?
Hard-refresh the docs page (Cmd+Shift+R on macOS, Ctrl+Shift+R on Windows). If the endpoints still don't appear, check your terminal for errors. The server must have reloaded successfully after you saved main.py.
help me troubleshoot my Swagger docs not showing my endpoints
Your API now accepts tutorials and hands them back on demand. Next up, you'll add a real database so your tutorials survive a server restart.
Add Database Persistence
Your API accepts and returns tutorials, but try restarting the server right now. Everything vanishes.
Real APIs need persistent storage. In this step, you will add SQLAlchemy (a Python ORM) backed by a SQLite file so your tutorials survive server restarts. This gives you production-grade ORM patterns with zero setup cost.
In this step, get ready to:
- Create a database connection layer and data models.
- Refactor your API endpoints to read and write from the database.
- Verify that data persists across server restarts.
Set up the database connection and model
SQLAlchemy lets you define your database tables as Python classes. You will create two files: one for the database connection and one for the table definitions.
- Create a new file called database.py in your tutorial-api folder and paste the following code:
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
# Point SQLAlchemy at a local SQLite file
SQLALCHEMY_DATABASE_URL = "sqlite:///./tutorials.db"
# Create the database engine
engine = create_engine(
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
)
# Create a session factory for database operations
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Base class that all models will inherit from
class Base(DeclarativeBase):
pass
What does this code do?
- SQLALCHEMY_DATABASE_URL tells SQLAlchemy to store data in a file called tutorials.db in your project folder.
- check_same_thread: False is required because FastAPI can access the database from multiple threads simultaneously.
- SessionLocal is a factory that creates fresh database sessions for each API request.
- Base is the parent class every database model will inherit from.
- Save database.py.
- Verify the module loads correctly by running this command in your terminal:
python -c "from database import Base; print('Database module ready')"
You should see Database module ready printed with no errors.
Seeing a ModuleNotFoundError?
- Make sure your virtual environment is activated. You should see (venv) at the start of your terminal prompt.
- Confirm that database.py is saved in the same folder where you are running the command.
- help me debug a ModuleNotFoundError when importing database.py
Now you need the models that define what a tutorial and a review look like in the database.
- Create a new file called models.py in your tutorial-api folder and paste the following code:
from datetime import datetime, timezone
from sqlalchemy import Column, Integer, String, Text, Float, ForeignKey, DateTime
from sqlalchemy.orm import relationship
from database import Base
class Tutorial(Base):
__tablename__ = "tutorials"
id = Column(Integer, primary_key=True, index=True)
title = Column(String, nullable=False)
content = Column(Text, nullable=False)
author = Column(String, nullable=False)
summary = Column(Text, default="")
what_you_will_learn = Column(Text, default="")
skills = Column(String, default="")
difficulty = Column(String, default="")
estimated_minutes = Column(Integer, default=0)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
reviews = relationship("Review", back_populates="tutorial")
What does this model define?
Each column maps directly to a field the API already returns. The summary, skills, and difficulty columns default to empty strings because AI will fill them in a later step.
The reviews relationship tells SQLAlchemy that one tutorial can have many reviews.
- Below the Tutorial class, add the Review model:
class Review(Base):
__tablename__ = "reviews"
id = Column(Integer, primary_key=True, index=True)
tutorial_id = Column(Integer, ForeignKey("tutorials.id"), nullable=False)
rating = Column(Integer, nullable=False)
comment = Column(Text, default="")
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
tutorial = relationship("Tutorial", back_populates="reviews")
What does the Review model add?
ForeignKey("tutorials.id") links each review to a specific tutorial. The back_populates on both sides creates a two-way relationship so you can navigate from a tutorial to its reviews and vice versa.
- Save models.py.
- Verify both models load correctly by running:
python -c "from models import Tutorial, Review; print('Models ready')"
You should see Models ready printed with no errors.
Seeing an ImportError?
- Check that from database import Base is on line 4 of models.py. The filename must match exactly.
- Make sure database.py and models.py are in the same folder.
- help me fix an ImportError in my SQLAlchemy models
✔️ Awesome, I've got everything!
Great. Make sure both database.py and models.py are saved.
ⓧ I'd like to double check the full code
Your database.py should contain:
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
SQLALCHEMY_DATABASE_URL = "sqlite:///./tutorials.db"
engine = create_engine(
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class Base(DeclarativeBase):
pass
Your models.py should have two classes in this order. Scroll up and confirm each is present:
- Imports (datetime, sqlalchemy columns, relationship, Base).
- Tutorial class with 10 columns and a reviews relationship.
- Review class with 5 columns and a tutorial relationship.
Refactor main.py to use database sessions
Right now main.py stores tutorials in a plain Python list. You will replace the entire file with code that reads and writes to your new SQLite database.
- Open main.py in VS Code.
- Select all the existing code (Cmd+A on macOS, Ctrl+A on Windows) and delete it.
- Paste the new imports, app setup, and database session dependency:
from fastapi import FastAPI, Depends, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import func
from database import SessionLocal, engine, Base
from models import Tutorial, Review
from schemas import TutorialCreate, TutorialResponse
# Create all database tables on startup
Base.metadata.create_all(bind=engine)
app = FastAPI(title="Tutorial Discovery API")
# Dependency: provides a fresh DB session per request
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
What changed from the old imports?
- Depends is FastAPI's dependency injection system. It lets each endpoint receive a database session automatically.
- Base.metadata.create_all(bind=engine) tells SQLAlchemy to create the tutorials and reviews tables if they do not exist yet.
- get_db() opens a session, hands it to the endpoint, and closes it when the request finishes.
- Below get_db(), add the helper function and root endpoint:
def tutorial_to_response(tutorial: Tutorial, db: Session) -> TutorialResponse:
# Calculate average rating from all reviews
avg_rating = db.query(func.avg(Review.rating)).filter(
Review.tutorial_id == tutorial.id
).scalar() or 0.0
review_count = db.query(func.count(Review.id)).filter(
Review.tutorial_id == tutorial.id
).scalar()
return TutorialResponse(
id=tutorial.id,
title=tutorial.title,
content=tutorial.content,
author=tutorial.author,
summary=tutorial.summary,
what_you_will_learn=tutorial.what_you_will_learn,
skills=tutorial.skills,
difficulty=tutorial.difficulty,
estimated_minutes=tutorial.estimated_minutes,
average_rating=round(float(avg_rating), 1),
review_count=review_count,
created_at=tutorial.created_at,
)
@app.get("/")
def root():
return {"message": "Tutorial Discovery API is running"}
Why a helper function?
tutorial_to_response() converts a raw database row into the response shape your API returns. It also computes the average rating on the fly by querying the reviews table. Every endpoint that returns a tutorial will use this one helper.
- Save main.py and start the server to confirm the imports work:
fastapi dev main.py
- Open http://127.0.0.1:8000 in your browser.
You should see {"message":"Tutorial Discovery API is running"}. This confirms your database modules imported correctly.
Server won't start?
- If you see ModuleNotFoundError, confirm that database.py, models.py, and main.py are all in the same folder.
- If you see a SyntaxError, check that the get_db() function ends with db.close() indented inside the finally: block.
- help me fix a startup error in my FastAPI app after adding SQLAlchemy
Keep the server running. It will auto-reload when you save new changes.
- Back in VS Code, add the POST endpoint below the root() function in main.py:
@app.post("/tutorials", response_model=TutorialResponse)
def create_tutorial(tutorial_data: TutorialCreate, db: Session = Depends(get_db)):
# Create a new Tutorial database record
tutorial = Tutorial(
title=tutorial_data.title,
content=tutorial_data.content,
author=tutorial_data.author,
summary="",
what_you_will_learn="",
skills="",
difficulty="",
estimated_minutes=0,
)
db.add(tutorial)
db.commit()
db.refresh(tutorial)
return tutorial_to_response(tutorial, db)
What does this endpoint do differently now?
Instead of appending to a Python list, the endpoint creates a real database row. db.add() stages the record, db.commit() writes it to disk, and db.refresh() reloads it with the auto-generated id and created_at values.
- Below the POST endpoint, add the two GET endpoints:
@app.get("/tutorials", response_model=list[TutorialResponse])
def list_tutorials(db: Session = Depends(get_db)):
tutorials = db.query(Tutorial).all()
return [tutorial_to_response(t, db) for t in tutorials]
@app.get("/tutorials/{tutorial_id}", response_model=TutorialResponse)
def get_tutorial(tutorial_id: int, db: Session = Depends(get_db)):
tutorial = db.query(Tutorial).filter(Tutorial.id == tutorial_id).first()
if not tutorial:
raise HTTPException(status_code=404, detail="Tutorial not found")
return tutorial_to_response(tutorial, db)
What do these queries do?
- db.query(Tutorial).all() fetches every tutorial from the database.
- .filter(Tutorial.id == tutorial_id).first() finds one specific tutorial by its ID, or returns None if it does not exist.
- Save main.py. The server auto-reloads.
- Open http://127.0.0.1:8000/docs in your browser.
You should see the interactive Swagger UI listing your three endpoints: POST /tutorials, GET /tutorials, and GET /tutorials/{tutorial_id}.
Server crashed on reload?
- Check that tutorial_to_response is defined before the endpoint functions that call it.
- Make sure there is no leftover code from the old in-memory version (tutorials_db = [] or global next_id).
- help me fix a crash in my refactored main.py after switching to SQLAlchemy
✔️ Awesome, I've got everything!
Great. Make sure main.py is saved and the server is running without errors.
ⓧ I'd like to double check the full code
Your main.py should have these sections in order. Scroll up and confirm each is present:
- Imports (FastAPI, Depends, HTTPException, Session, func, plus your local modules).
- Base.metadata.create_all(bind=engine) and app = FastAPI(title="Tutorial Discovery API").
- get_db() dependency function with yield.
- tutorial_to_response() helper computing average rating and review count.
- root() endpoint at GET /.
- create_tutorial() endpoint at POST /tutorials.
- list_tutorials() endpoint at GET /tutorials.
- get_tutorial() endpoint at GET /tutorials/{tutorial_id}.
Test that data persists across restarts
The whole point of adding a database is that tutorials survive a server restart. Time to prove it.
- Open http://127.0.0.1:8000/docs in your browser.
- Expand the POST /tutorials section and click Try it out.
- Replace the example JSON body with this:
{
"title": "Setting up a Python virtual environment",
"content": "Virtual environments isolate your project dependencies from the system Python. Run python -m venv venv to create one, then activate it with source venv/bin/activate on macOS or venv/Scripts/activate on Windows.",
"author": "NextWork"
}
- Click Execute.
You should see a 200 response with your tutorial returned, including an auto-generated id and created_at timestamp.
Before you stop the server, what do you think will happen when you restart it and request GET /tutorials? Will the tutorial still be there, or will the list be empty?
- Go back to your terminal and press Ctrl+C to stop the server.
- Restart the server by running:
fastapi dev main.py
- Open http://127.0.0.1:8000/docs again.
- Expand GET /tutorials, click Try it out, then Execute.
Your tutorial is still there. The data survived the restart because it lives in the tutorials.db file on disk, not in memory.
Where is the database file?
Look in your tutorial-api folder. You will see a new file called tutorials.db. SQLAlchemy created it automatically the first time the server started after your refactor.
Tutorial list is empty after restart?
- Make sure you clicked Execute on the POST request and saw a 200 response before stopping the server.
- Check that tutorials.db exists in your project folder. If it is missing, confirm that SQLALCHEMY_DATABASE_URL in database.py is set to sqlite:///./tutorials.db.
- help me figure out why my tutorials are not persisting across restarts
Your API now has real persistence. Tutorials survive restarts, and the database is ready for the review system you will build later. Next up, you will wire in Google Gemini to auto-generate metadata for every tutorial submission.
Integrate Gemini AI for Auto-Enrichment
Your API accepts tutorials and stores them in the database. But right now, every tutorial comes back with empty metadata fields. No summary, no skill tags, no difficulty level.
Manually filling those fields for every submission would be tedious and inconsistent. In this step, you'll connect Google Gemini AI to auto-generate structured metadata from raw tutorial content.
In this step, get ready to:
- Create an AI enrichment module that calls Gemini with structured output.
- Wire the AI into your POST endpoint so every new tutorial is enriched automatically.
- Test the full flow and see AI-generated metadata appear in your API responses.
Create the AI enrichment module
The Gemini Interactions API can return structured JSON that matches a schema you define. You'll use a Pydantic model to describe the exact metadata shape you want, and Gemini will guarantee its response matches.
- Create a new file called ai.py in the tutorial-api folder.
- Add the imports and the TutorialMetadata schema by pasting this code:
import json
from google import genai
from pydantic import BaseModel, Field
from typing import List
# Define the exact shape of metadata we want from Gemini
class TutorialMetadata(BaseModel):
summary: str = Field(description="A 1-2 sentence summary of what this tutorial teaches.")
what_you_will_learn: List[str] = Field(description="3-5 bullet points of what the reader will learn.")
skills: List[str] = Field(description="Technical skill tags, e.g. Python, FastAPI, Docker.")
difficulty: str = Field(description="One of: Beginner, Intermediate, Advanced.")
estimated_minutes: int = Field(description="Estimated time to complete in minutes.")
What does this code do?
The TutorialMetadata class defines a contract for what Gemini must return. Each field has a description that tells Gemini what to generate for that field.
When you pass this model's JSON schema to Gemini, it forces the AI to return valid JSON matching exactly these field names and types. No free-form text, no guessing at the structure.
- Below the TutorialMetadata class, add the client initialization and the beginning of the enrich_tutorial function:
# Initialize the Gemini client (auto-detects GEMINI_API_KEY env var)
client = genai.Client()
def enrich_tutorial(title: str, content: str) -> dict:
# Build the prompt with the tutorial's title and content
prompt = f"""Analyze this developer tutorial and generate metadata for it.
Title: {title}
Content: {content}
Generate appropriate metadata based on the tutorial's content and complexity."""
What does this code do?
The genai.Client() call creates a Gemini client that auto-detects your GEMINI_API_KEY environment variable. No manual key passing needed.
The enrich_tutorial function takes a tutorial's title and content, then builds a prompt asking Gemini to analyze the tutorial and generate metadata.
- Still inside the enrich_tutorial function, add the API call and return logic directly below the prompt:
# Call Gemini with structured output enforcing our schema
interaction = client.interactions.create(
model="gemini-2.5-flash",
input=prompt,
response_format={
"type": "text",
"mime_type": "application/json",
"schema": TutorialMetadata.model_json_schema()
},
)
# Parse the JSON response into our Pydantic model
metadata = TutorialMetadata.model_validate_json(interaction.output_text)
# Return a dict with values formatted for database storage
return {
"summary": metadata.summary,
"what_you_will_learn": "\n".join(f"- {item}" for item in metadata.what_you_will_learn),
"skills": ", ".join(metadata.skills),
"difficulty": metadata.difficulty,
"estimated_minutes": metadata.estimated_minutes,
}
What does this code do?
- The response_format parameter forces Gemini to return JSON matching your Pydantic schema exactly.
- The model_validate_json call parses and validates the response, catching any malformed output.
- The return dictionary joins lists into strings (bullets with newlines, skills with commas) so they fit cleanly into the database text columns.
- Save ai.py.
- Confirm the file exists by running:
ls ai.py
You should see ai.py listed in the output.
Don't see ai.py?
Make sure you created the file inside the tutorial-api folder, not inside venv or a subfolder.
help me find where ai.py was saved
✔️ Awesome, I've got everything!
Great. Double check you've saved ai.py and move on to the next substep.
ⓧ I'd like to double check the full code
Scroll up and confirm all three chunks are present in your ai.py file, in this order:
- Imports and the TutorialMetadata Pydantic class with 5 fields (summary, what_you_will_learn, skills, difficulty, estimated_minutes).
- The client = genai.Client() line followed by the enrich_tutorial function definition and prompt.
- The client.interactions.create call, the model_validate_json parsing, and the return dictionary.
Wire AI into the POST endpoint
Now you need to connect your new AI module to the existing POST endpoint so every submitted tutorial gets enriched before being saved.
- In main.py, find the import section at the top of the file. Find this line:
from schemas import TutorialCreate, TutorialResponse
- Add this import directly below it:
from ai import enrich_tutorial
- Now find the create_tutorial function. It currently looks like this:
@app.post("/tutorials", response_model=TutorialResponse)
def create_tutorial(tutorial_data: TutorialCreate, db: Session = Depends(get_db)):
tutorial = Tutorial(
title=tutorial_data.title,
content=tutorial_data.content,
author=tutorial_data.author,
summary="",
what_you_will_learn="",
skills="",
difficulty="",
estimated_minutes=0,
)
db.add(tutorial)
db.commit()
db.refresh(tutorial)
return tutorial_to_response(tutorial, db)
- Replace that entire function with this updated version that calls the AI before saving:
@app.post("/tutorials", response_model=TutorialResponse)
def create_tutorial(tutorial_data: TutorialCreate, db: Session = Depends(get_db)):
# Call Gemini to generate metadata from the tutorial content
metadata = enrich_tutorial(tutorial_data.title, tutorial_data.content)
tutorial = Tutorial(
title=tutorial_data.title,
content=tutorial_data.content,
author=tutorial_data.author,
summary=metadata["summary"],
what_you_will_learn=metadata["what_you_will_learn"],
skills=metadata["skills"],
difficulty=metadata["difficulty"],
estimated_minutes=metadata["estimated_minutes"],
)
db.add(tutorial)
db.commit()
db.refresh(tutorial)
return tutorial_to_response(tutorial, db)
What changed?
Instead of hardcoding empty strings for the metadata fields, the endpoint now calls enrich_tutorial first. The AI generates structured metadata, and the function stores those values alongside the user-provided title, content, and author.
- Save main.py.
✔️ Awesome, I've got everything!
Great. Double check you've saved main.py and move on to testing.
ⓧ I'd like to double check the full code
Your main.py should have these key parts in order:
- Imports including from ai import enrich_tutorial as the last import line.
- The get_db dependency and tutorial_to_response helper (unchanged from before).
- The create_tutorial function now starts with metadata = enrich_tutorial(...) before creating the Tutorial object.
- The GET endpoints for /tutorials and /tutorials/{tutorial_id} remain unchanged.
Test the AI enrichment
Time to see the AI in action. You'll submit a tutorial and watch Gemini fill in all the metadata fields automatically.
- Start the server by running:
fastapi dev main.py
You should see Uvicorn running on http://127.0.0.1:8000 in the terminal.
- Open http://127.0.0.1:8000/docs in your browser to load the Swagger UI.
- Click the POST /tutorials endpoint to expand it.
- Click Try it out.
- Replace the request body with this JSON:
{
"title": "How to use Python list comprehensions",
"content": "List comprehensions provide a concise way to create lists in Python. Instead of writing a for loop to build a list, you can express the same logic in a single line. The syntax is [expression for item in iterable if condition]. They are faster than equivalent for loops and are considered more Pythonic. Common use cases include filtering lists, transforming data, and creating new lists from existing ones.",
"author": "NextWork"
}
Before you hit Execute, predict: will the response come back with empty metadata fields like before, or will they be filled in?
- Click Execute.
The response now includes AI-generated metadata. You should see a summary describing the tutorial, skills like "Python, list-comprehensions", a difficulty level, and an estimated_minutes value.
Why does it take a moment?
The POST request now makes a round-trip to the Gemini API before responding. This adds a few seconds of latency compared to the instant responses you saw before. That trade-off is worth it for automatically generated, high-quality metadata.
- Submit another tutorial on a different topic to confirm the AI adapts. Replace the request body with:
{
"title": "Getting started with Docker containers",
"content": "Docker lets you package applications into containers that run consistently across any environment. A Dockerfile defines the build steps, docker build creates an image, and docker run starts a container from that image. Containers are isolated, lightweight, and share the host OS kernel.",
"author": "NextWork"
}
- Click Execute.
This time the skills field should include tags like "Docker" and "containers" instead of Python-related tags. The difficulty and time estimate should reflect the different topic.
Seeing a 500 Internal Server Error?
- Check your terminal for the error traceback. If it mentions GEMINI_API_KEY, make sure you set the environment variable in the same terminal session where you ran fastapi dev main.py.
- If you see an import error for ai, confirm that ai.py is in the same folder as main.py.
- If you see a validation error from Pydantic, make sure you copied the response_format dictionary exactly as shown.
help me debug my Gemini AI integration error
Your API now transforms raw tutorial submissions into polished, discoverable content automatically. Next up, you'll add a review and rating system so the best tutorials can surface to the top.
Add the Review and Rating System
You have a tutorial platform that accepts content and enriches it with AI. But content without curation is noise.
A rating and review system lets the best tutorials surface naturally. It also demonstrates a real relational data model: tutorials have many reviews, and computed averages update live as new reviews arrive.
In this step, get ready to:
- Wire the existing Review model and schemas into your API endpoints.
- Build POST and GET review endpoints with proper error handling.
- Test that submitting reviews updates computed averages in tutorial listings.
Update the import to include review schemas
Your models.py already defines the Review model with a foreign key to Tutorial. Your schemas.py already has ReviewCreate and ReviewResponse ready to go.
The one missing piece is importing those schemas into main.py so you can use them in your new endpoints.
- In main.py, find this import line near the top:
from schemas import TutorialCreate, TutorialResponse
- Replace it with this updated import that includes the review schemas:
from schemas import TutorialCreate, TutorialResponse, ReviewCreate, ReviewResponse
Add the create review endpoint
This endpoint lets users rate and comment on tutorials. It validates that the tutorial exists before accepting the review.
- Add the following function at the bottom of main.py, below the get_tutorial function:
@app.post("/tutorials/{tutorial_id}/reviews", response_model=ReviewResponse)
def create_review(
tutorial_id: int, review_data: ReviewCreate, db: Session = Depends(get_db)
):
# Check the tutorial exists before accepting a review
tutorial = db.query(Tutorial).filter(Tutorial.id == tutorial_id).first()
if not tutorial:
raise HTTPException(status_code=404, detail="Tutorial not found")
# Create and save the review
review = Review(
tutorial_id=tutorial_id,
rating=review_data.rating,
comment=review_data.comment,
)
db.add(review)
db.commit()
db.refresh(review)
return ReviewResponse(
id=review.id,
tutorial_id=review.tutorial_id,
rating=review.rating,
comment=review.comment,
created_at=review.created_at,
)
What does this code do?
This endpoint handles the full lifecycle of creating a review:
- It first queries the database to confirm the tutorial exists. If not, it returns a 404 error.
- It creates a Review record with the user's rating (1-5) and optional comment, linked to the tutorial by ID.
- After committing to the database, it returns the saved review with its auto-generated id and created_at timestamp.
- Save main.py.
- Switch to your browser and open http://127.0.0.1:8000/docs. The dev server auto-reloads on save.
- You should see a new POST /tutorials/{tutorial_id}/reviews endpoint in the Swagger UI.
- Click on it, then click Try it out.
- Enter 1 for tutorial_id and use this request body:
{
"rating": 5,
"comment": "Clear and concise tutorial!"
}
- Click Execute.
- You should see a 200 response containing your review with an auto-assigned id and created_at timestamp.
Seeing a 404 Tutorial not found?
This means tutorial ID 1 does not exist in your database. If you deleted tutorials.db or restarted fresh, submit a new tutorial first using POST /tutorials before posting a review.
Help me debug this
Add the list reviews endpoint
Users need to read all reviews for a tutorial, not just write them. This endpoint returns every review attached to a given tutorial.
- Add the following function at the bottom of main.py, below the create_review function:
@app.get("/tutorials/{tutorial_id}/reviews", response_model=list[ReviewResponse])
def list_reviews(tutorial_id: int, db: Session = Depends(get_db)):
# Verify the tutorial exists
tutorial = db.query(Tutorial).filter(Tutorial.id == tutorial_id).first()
if not tutorial:
raise HTTPException(status_code=404, detail="Tutorial not found")
# Fetch all reviews for this tutorial
reviews = db.query(Review).filter(Review.tutorial_id == tutorial_id).all()
return [
ReviewResponse(
id=r.id,
tutorial_id=r.tutorial_id,
rating=r.rating,
comment=r.comment,
created_at=r.created_at,
)
for r in reviews
]
What does this code do?
- It checks whether the tutorial exists first, returning a 404 if not.
- It queries all Review records where tutorial_id matches the URL parameter.
- It returns a list of ReviewResponse objects, each with the review's rating, comment, and timestamp.
- Save main.py.
- In the Swagger UI, find the new GET /tutorials/{tutorial_id}/reviews endpoint.
- Click Try it out, enter 1 for tutorial_id, and click Execute.
- You should see a JSON array containing the review you submitted earlier.
Getting an empty list?
Make sure you are querying the same tutorial_id you posted your review to. If you submitted the review to tutorial 1, query tutorial 1 here.
Help me figure out why my reviews aren't showing
Test the full review and rating flow
The real payoff of the review system is that tutorial listings now show computed ratings. The tutorial_to_response helper already calculates average_rating and review_count from the reviews table.
- In Swagger UI, use GET /tutorials and click Execute.
- Find the tutorial you reviewed. It should now show average_rating of 5.0 and review_count of 1.
Now submit a second review with a different rating to confirm the average recalculates.
- Go back to POST /tutorials/{tutorial_id}/reviews.
- Enter 1 for tutorial_id and use this request body:
{
"rating": 3,
"comment": "Good content but could use more examples."
}
- Click Execute.
Before you check the updated tutorial listing, what do you think the new average_rating will be? One review was a 5, this one is a 3.
- Use GET /tutorials again and click Execute.
- Your tutorial now shows average_rating of 4.0 and review_count of 2. The average updates live as new reviews come in.
How does the average get computed?
The tutorial_to_response helper in main.py runs a SQL AVG() query across all reviews for that tutorial every time a tutorial is returned. This means ratings are always fresh without any caching complexity.
For a production API with millions of reviews, you would denormalize this into a column on the tutorials table. For a portfolio project, computing on the fly keeps the code simple and correct.
Average rating still showing 0.0?
Make sure you posted your reviews to the same tutorial_id you are now querying. The tutorial_to_response helper only counts reviews linked to that specific tutorial.
Help me debug my average rating
✔️ Awesome, I've got everything!
Great. Make sure you have saved main.py and confirmed the ratings update live in Swagger.
ⓧ I'd like to double check the full code
Your main.py should now contain these sections in order. Scroll through your file and confirm each is present:
- Imports (including ReviewCreate and ReviewResponse from schemas).
- The Base.metadata.create_all call and app initialization.
- The get_db dependency function.
- The tutorial_to_response helper function.
- The root, create_tutorial, list_tutorials, and get_tutorial endpoints.
- The create_review endpoint (POST).
- The list_reviews endpoint (GET).
Your API now has a full review and rating system. The best tutorials surface naturally through user ratings. Next up, you will tackle the Secret Mission and add search and filtering to make this a real discovery platform.
Secret mission
Add Search and Filtering
Your API stores tutorials, but users have no way to find specific ones. Add query parameters to GET /tutorials so users can filter by skill tag, difficulty level, minimum rating, and keyword search across titles and summaries.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with a free-tier API key, so there are no ongoing costs.
Resources you used:
- tutorial-api project folder (contains all Python files and the virtual environment).
- tutorials.db SQLite database file (inside the project folder).
- Gemini API key (free tier, no charges while unused).
Keep everything running
No action needed. Choose this if you are still actively building or plan to extend the project.
- The SQLite database and all project files live inside the tutorial-api folder on your Desktop. They cost nothing to store.
- Your Gemini API key remains active on the free tier. It incurs no charges while unused.
- The Python virtual environment stays inside the project folder and does not affect the rest of your system.
Pause - I'll come back to this later
Shut down any running processes to free up memory. Your files and database stay intact for next time.
- If the FastAPI server is still running, press Ctrl+C in the terminal to stop it.
- Close the VS Code window for the project.
- Everything will be waiting in your tutorial-api folder when you return. Reactivate the virtual environment and run fastapi dev main.py to pick up where you left off.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow each step below.
- If the FastAPI server is still running, press Ctrl+C in the terminal to stop it.
- Delete the project folder by running this command in your terminal:
macOS
cd ~/Desktop
rm -rf tutorial-api
Windows
cd ~\Desktop
rmdir /s /q tutorial-api
- Revoke your Gemini API key so it cannot be used by anyone:
- Open the Google Cloud API Credentials page at console.cloud.google.com/apis/credentials.
- Find the API key you created for this project and click the Actions icon (three dots).
- Select Delete API key.
- In the confirmation modal, click Delete. Deletion takes a few minutes to propagate.
Nice Work!
Nice Work!
Nice work! You just built a production-ready backend API that accepts developer tutorials and automatically enriches them with AI-generated metadata.
You've learned how to:
- Build a REST API with FastAPI featuring full CRUD operations and auto-generated interactive Swagger documentation.
- Integrate Google Gemini AI to auto-generate structured metadata (summaries, skill tags, difficulty levels) from raw tutorial content.
- Model relational data with SQLAlchemy ORM and implement a review and rating system with computed averages.
- Secret Mission: Added multi-criteria search and filtering with combinable query parameters.
Ready to quiz yourself?