🗂️ Resource-Based API Structure
A great API feels obvious. You can guess the URL for "this user's orders" without reading the docs, because the API is organized around things (resources) rather than actions. This lesson teaches you how to identify resources, design clean URIs, map HTTP methods to CRUD, and model relationships — then implement it in Flask-RESTful.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Identify the resources in an application and classify them (collection, instance, nested, singleton, controller)
- Design URIs that follow REST conventions and choose between path and query parameters
- Map HTTP methods to CRUD operations and reason about safe vs. idempotent methods
- Model relationships with nested resources without over-nesting
- Implement a resource-based API in Flask-RESTful with SQLAlchemy 2.0 models
Estimated Time: 40–55 minutes • Difficulty: Intermediate
Hands-on: Design the full URI map for a task-manager API, then verify against a reference solution.
In This Lesson
Nouns, Not Verbs
The single most important idea in REST design is this: organize your API around resources (nouns), not actions (verbs). A resource is any meaningful entity your application manages — a user, a post, an order, a comment. The HTTP method is the verb, so you never need one in the URL.
| Action-oriented (avoid) | Resource-oriented (prefer) |
|---|---|
POST /createUser | POST /users |
GET /getUserById?id=5 | GET /users/5 |
POST /updateUser | PUT /users/5 |
GET /deleteUser?id=5 | DELETE /users/5 |
💡 Analogy: Think about how you handle a physical book. You can pick it up (GET), acquire a new one (POST), replace it with a corrected edition (PUT), or throw it away (DELETE). The book is the resource; picking-up and throwing-away are the verbs. A resource-oriented API works exactly the same way.
/users/:id/posts express relationships.Identifying Resources
Before writing any URLs, list the key entities in your domain. These usually become your resources. A useful process:
- Model the domain: what are the main "things" users care about?
- Favor nouns: if a candidate is really an action, it probably belongs to a resource's method or a controller resource.
- Follow the CRUD test: can it be created, read, updated, or deleted? Then it's a resource.
- Keep granularity consistent: resources should sit at a similar level of abstraction.
Five kinds of resource
| Type | Description | Example |
|---|---|---|
| Collection | A group of similar items | /users, /posts |
| Instance | One item within a collection | /users/123 |
| Nested | Items belonging to another resource | /users/123/posts |
| Singleton | Exists as a single instance per context | /profile, /settings |
| Controller | An operation that doesn't fit pure CRUD | /search, /reports/generate |
📖 Worked example: an e-commerce domain
For an online shop, the resources fall out naturally: products (things for sale), users (customers and admins), orders (purchases), reviews (feedback on a product), and a cart (a per-user singleton). Notice every one is a noun, and each supports some subset of CRUD.
Designing Clean URIs
Once you have resources, give them predictable URIs. A few battle-tested principles:
✅ URI conventions
- Nouns, not verbs:
/users, not/getUsers. - Plural collections:
/posts, not/post. - Concrete names:
/invoices, not/data. - Hierarchy for relationships:
/users/123/orders. - One consistent case: pick
kebab-case(/order-items) and stick to it; avoid/orderItems. - Keep them short: resist the urge to encode your whole architecture in the path.
Path parameters vs. query parameters
This is a decision you'll make constantly. The rule of thumb:
- Path parameter — identifies a specific resource. It's part of the resource's address:
/users/123,/orders/456. - Query parameter — modifies a collection request: filtering, sorting, paging, searching. It doesn't identify a single resource:
/users?role=admin&sort=name&page=2.
# Identify (path parameter)
GET /products/xyz-123
# Filter, sort, paginate (query parameters)
GET /products?category=electronics&min_price=100&sort=-price&page=2&per_page=20
Common URI patterns
| Type | Pattern | Example |
|---|---|---|
| Collection | /resources | /users |
| Instance | /resources/:id | /users/123 |
| Nested collection | /resources/:id/sub | /users/123/orders |
| Nested instance | /resources/:id/sub/:sub_id | /users/123/orders/456 |
| Singleton | /resource | /profile |
| Controller | /verb-phrase | /search |
HTTP Methods & CRUD
HTTP methods are the verbs that act on your resource nouns. Four of them cover the CRUD lifecycle:
| Method | CRUD | Typical use |
|---|---|---|
GET | Read | GET /users (list), GET /users/5 (one) |
POST | Create | POST /users |
PUT | Update (replace) | PUT /users/5 |
PATCH | Update (partial) | PATCH /users/5 |
DELETE | Delete | DELETE /users/5 |
Safe and idempotent methods
Two properties shape how clients (and caches, and proxies) can treat a request:
- Safe — the request doesn't change server state.
GET,HEAD, andOPTIONSare safe. - Idempotent — making the same request many times has the same effect as making it once.
GET,PUT,DELETE,HEAD, andOPTIONSare idempotent;POSTis not.
💡 Why idempotency matters in practice
If a client's network drops mid-request, it can safely retry an idempotent call without side effects. Retrying a PUT /users/5 just re-sets the same values. Retrying a POST /users, however, might create a duplicate user — which is exactly why creation flows sometimes use an "idempotency key."
Relationships & Nesting
Resources rarely stand alone. A user has many orders; an order belongs to a user. REST expresses these relationships mainly through nested URIs.
# All orders belonging to user 123
GET /users/123/orders
# A specific order for that user
GET /users/123/orders/456
# Create an order for user 123
POST /users/123/orders
Don't over-nest
Nesting is expressive but gets unwieldy fast. As a rule, stop at one or two levels. Beyond that, address the deeper resource directly.
# Too deep — hard to read, brittle
GET /users/123/orders/456/items/789/attributes
# Better — go straight to the resource, filter with a query param
GET /order-items/789
GET /order-items?order_id=456
HATEOAS: links in the response
You can make relationships discoverable by embedding links in the representation. This is the idea behind HATEOAS (Hypermedia as the Engine of Application State): the response tells the client where it can go next, so URLs don't have to be hard-coded.
{
"id": 123,
"name": "John Doe",
"_links": {
"self": { "href": "/users/123" },
"orders": { "href": "/users/123/orders" },
"profile": { "href": "/users/123/profile" }
}
}
Implementing in Flask-RESTful
Let's turn design into code. We'll build a slice of a task-manager API — users, categories, and tasks — using SQLAlchemy 2.0 typed models and Flask-RESTful resources. Here are the models:
from datetime import datetime, timezone
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import ForeignKey
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from werkzeug.security import generate_password_hash, check_password_hash
class Base(DeclarativeBase):
pass
db = SQLAlchemy(model_class=Base)
def utcnow() -> datetime:
return datetime.now(timezone.utc)
class User(db.Model):
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(db.String(50), unique=True)
email: Mapped[str] = mapped_column(db.String(120), unique=True)
password_hash: Mapped[str] = mapped_column(db.String(256))
created_at: Mapped[datetime] = mapped_column(default=utcnow)
tasks: Mapped[list["Task"]] = relationship(
back_populates="owner", cascade="all, delete-orphan"
)
def set_password(self, password: str) -> None:
self.password_hash = generate_password_hash(password)
def check_password(self, password: str) -> bool:
return check_password_hash(self.password_hash, password)
class Category(db.Model):
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(db.String(50))
user_id: Mapped[int] = mapped_column(ForeignKey("user.id"))
tasks: Mapped[list["Task"]] = relationship(back_populates="category")
class Task(db.Model):
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(db.String(100))
completed: Mapped[bool] = mapped_column(default=False)
priority: Mapped[int] = mapped_column(default=1) # 1=Low, 2=Med, 3=High
created_at: Mapped[datetime] = mapped_column(default=utcnow)
user_id: Mapped[int] = mapped_column(ForeignKey("user.id"))
category_id: Mapped[int | None] = mapped_column(ForeignKey("category.id"))
owner: Mapped["User"] = relationship(back_populates="tasks")
category: Mapped["Category | None"] = relationship(back_populates="tasks")
The URI map
Design the endpoints before coding the resources. This is the contract:
| Resource | URI | Methods |
|---|---|---|
| Users collection | /users | GET, POST |
| User instance | /users/<id> | GET, PUT, DELETE |
| Tasks collection | /tasks | GET, POST |
| Task instance | /tasks/<id> | GET, PUT, DELETE |
| A user's tasks (nested) | /users/<id>/tasks | GET |
| Complete a task (controller) | /tasks/<id>/complete | PUT |
Resources for tasks
from flask_restful import Resource, reqparse, fields, marshal_with, abort
from sqlalchemy import desc
task_fields = {
"id": fields.Integer,
"title": fields.String,
"completed": fields.Boolean,
"priority": fields.Integer,
"user_id": fields.Integer,
"category_id": fields.Integer,
}
task_parser = reqparse.RequestParser()
task_parser.add_argument("title", type=str, required=True, help="Title is required")
task_parser.add_argument("priority", type=int, default=1)
task_parser.add_argument("user_id", type=int, required=True, help="user_id is required")
task_parser.add_argument("category_id", type=int)
class TaskList(Resource):
@marshal_with(task_fields)
def get(self):
return db.session.scalars(
db.select(Task).order_by(desc(Task.created_at))
).all()
@marshal_with(task_fields)
def post(self):
args = task_parser.parse_args()
if db.session.get(User, args["user_id"]) is None:
abort(404, message=f"User {args['user_id']} not found")
task = Task(
title=args["title"],
priority=args["priority"],
user_id=args["user_id"],
category_id=args["category_id"],
)
db.session.add(task)
db.session.commit()
return task, 201
class TaskItem(Resource):
@marshal_with(task_fields)
def get(self, task_id):
task = db.session.get(Task, task_id)
if task is None:
abort(404, message=f"Task {task_id} not found")
return task
def delete(self, task_id):
task = db.session.get(Task, task_id)
if task is None:
abort(404, message=f"Task {task_id} not found")
db.session.delete(task)
db.session.commit()
return "", 204
class UserTasks(Resource):
"""Nested resource: the tasks belonging to one user."""
@marshal_with(task_fields)
def get(self, user_id):
user = db.session.get(User, user_id)
if user is None:
abort(404, message=f"User {user_id} not found")
return user.tasks
class TaskComplete(Resource):
"""Controller resource: an action that isn't plain CRUD."""
@marshal_with(task_fields)
def put(self, task_id):
task = db.session.get(Task, task_id)
if task is None:
abort(404, message=f"Task {task_id} not found")
task.completed = True
db.session.commit()
return task
Registering with an application factory
from flask import Flask
from flask_restful import Api
def create_app():
app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///tasks.db"
db.init_app(app)
api = Api(app)
api.add_resource(TaskList, "/tasks")
api.add_resource(TaskItem, "/tasks/<int:task_id>")
api.add_resource(UserTasks, "/users/<int:user_id>/tasks")
api.add_resource(TaskComplete, "/tasks/<int:task_id>/complete")
with app.app_context():
db.create_all()
return app
if __name__ == "__main__":
create_app().run(debug=True)
✅ Notice the mapping
Each class in the code corresponds to exactly one row in the URI map. A collection (TaskList), an instance (TaskItem), a nested resource (UserTasks), and a controller (TaskComplete). Your design document is your architecture.
Hands-on Exercise
🏋️ Design an E-commerce URI Map
Objective: Practice resource-based design before writing any code — the skill that separates clean APIs from messy ones.
The domain:
- User — a customer or admin
- Product — an item for sale
- Order — a purchase by a user, containing order items
- Review — a user's review of a product
Your task:
- List every resource and classify it (collection / instance / nested / controller).
- Write the URI and allowed HTTP methods for each.
- Decide: should "all reviews for a product" be nested, or use a query parameter? Justify it.
- Add one controller resource for an action that isn't plain CRUD (e.g. "checkout an order").
💡 Hint
Reviews clearly belong to a product, so /products/:id/reviews reads well and expresses ownership — a good case for nesting. "Checkout" is a verb, so it becomes a controller resource under the order it acts on.
✅ Reference solution
| URI | Methods | Type |
|---|---|---|
/users | GET, POST | Collection |
/users/:id | GET, PUT, DELETE | Instance |
/products | GET, POST | Collection |
/products/:id | GET, PUT, DELETE | Instance |
/products/:id/reviews | GET, POST | Nested |
/orders | GET, POST | Collection |
/orders/:id | GET, DELETE | Instance |
/users/:id/orders | GET | Nested |
/orders/:id/checkout | POST | Controller |
Reviews are nested because they're conceptually owned by a product and you'll almost always fetch them in that context. Checkout is a controller resource — it's a state-changing action, so POST is appropriate (it isn't idempotent).
Best Practices
✅ Do
- Use plural nouns for collections and one consistent casing everywhere.
- Use path params to identify, query params to filter/sort/paginate.
- Paginate every collection endpoint and return metadata (
total,page, links). - Keep error formats consistent across the whole API.
- Version your API (
/api/v1/...) so you can evolve it without breaking clients.
⚠️ Don't
- Don't put verbs in resource URLs (
/getUsers,/deleteOrder). - Don't nest more than one or two levels deep.
- Don't use
GETfor anything that changes state — it's supposed to be safe. - Don't mix singular and plural, or camelCase and kebab-case, across endpoints.
- Don't return unbounded collections — always cap the page size.
Summary & Quiz
🎉 Key Takeaways
- Design APIs around resources (nouns); let HTTP methods be the verbs.
- Classify resources as collection, instance, nested, singleton, or controller, and give each a clean URI.
- Use path parameters to identify and query parameters to filter, sort, and paginate.
- Express relationships with shallow nesting; optionally add HATEOAS links.
- Each resource class in Flask-RESTful maps to exactly one row of your URI design.
🎯 Quick Quiz
Question 1: Which URL best follows resource-based REST conventions for deleting user 5?
Question 2: You want to fetch only the admin users, sorted by name. What's the right approach?
Question 3: Which statement about HTTP methods is true?
📚 Further Reading
- RESTful API Design Guidelines
- Martin Fowler — The Richardson Maturity Model
- JSON:API Specification
- Flask-RESTful Documentation
🚀 What's Next?
Your API is well-structured, but reqparse and manual field dicts start to strain on complex data. Next we'll adopt Marshmallow — a dedicated library for serialization, deserialization, and rich validation that scales far better as your schemas grow.