Skip to main content

🗂️ 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 /createUserPOST /users
GET /getUserById?id=5GET /users/5
POST /updateUserPUT /users/5
GET /deleteUser?id=5DELETE /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.
A resource-oriented API tree The API root branches into resource collections such as users and posts, and each collection branches into instances and nested collections. /api /users /posts /users/:id /users/:id/posts /posts/:id
Figure 1 — A resource tree. Collections (amber) hold instances (green); nested collections like /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

TypeDescriptionExample
CollectionA group of similar items/users, /posts
InstanceOne item within a collection/users/123
NestedItems belonging to another resource/users/123/posts
SingletonExists as a single instance per context/profile, /settings
ControllerAn 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 parameteridentifies a specific resource. It's part of the resource's address: /users/123, /orders/456.
  • Query parametermodifies 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

TypePatternExample
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:

MethodCRUDTypical use
GETReadGET /users (list), GET /users/5 (one)
POSTCreatePOST /users
PUTUpdate (replace)PUT /users/5
PATCHUpdate (partial)PATCH /users/5
DELETEDeleteDELETE /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, and OPTIONS are safe.
  • Idempotent — making the same request many times has the same effect as making it once. GET, PUT, DELETE, HEAD, and OPTIONS are idempotent; POST is 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."

graph TD A[Collection /resources] --> B[GET — list all] A --> C[POST — create one] D[Instance /resources/:id] --> E[GET — read one] D --> F[PUT / PATCH — update] D --> G[DELETE — remove]

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:

ResourceURIMethods
Users collection/usersGET, POST
User instance/users/<id>GET, PUT, DELETE
Tasks collection/tasksGET, POST
Task instance/tasks/<id>GET, PUT, DELETE
A user's tasks (nested)/users/<id>/tasksGET
Complete a task (controller)/tasks/<id>/completePUT

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:

  1. List every resource and classify it (collection / instance / nested / controller).
  2. Write the URI and allowed HTTP methods for each.
  3. Decide: should "all reviews for a product" be nested, or use a query parameter? Justify it.
  4. 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
URIMethodsType
/usersGET, POSTCollection
/users/:idGET, PUT, DELETEInstance
/productsGET, POSTCollection
/products/:idGET, PUT, DELETEInstance
/products/:id/reviewsGET, POSTNested
/ordersGET, POSTCollection
/orders/:idGET, DELETEInstance
/users/:id/ordersGETNested
/orders/:id/checkoutPOSTController

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 GET for 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

🚀 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.