Introduction
Representational State Transfer, or REST, is an architectural style for designing web services and APIs. It provides a set of guidelines and principles that help developers create reliable, scalable, and maintainable systems.
In REST, resources are represented as unique uniform resource identifiers (URIs), and the state of these resources is transferred between the client and server through HTTP methods like GET, POST, PUT, TRACE, and DELETE.
REST encourages a stateless communication model, meaning that the server does not store any information about the client’s requests. This simplifies the system design and allows for better scalability. RESTful APIs are widely used in modern web development. Understanding REST is essential for building robust and interoperable software systems.
This article covers the architectural principles and constraints of REST, then shows an example of a RESTful API implemented in Python.
REST architectural constraints
Although REST is not a standard, it specifies six architectural constraints that a designer of web service or API needs to follow if the service is to be called RESTful. These are uniform interface, statelessness, cacheability, client-server design, layered system architecture, and, optionally, code-on-demand functionality.
Next, we take a closer look at each of these principles.
1) Uniform interface
The uniform interface is a fundamental principle of REST that provides a consistent way for clients to interact with resources. It simplifies communication and promotes interoperability. It has four components:
- Resource-based
- Manipulation of resources through representation
- Self-descriptive messages
- Hypermedia as the engine of the application state (HATEOS)
A) Resource-based
The resource in each client request is uniquely identified with URIs, more specifically with URLs.
These resources exist independently of any specific representation that is sent back to the client. For instance, when a client requests data from the server, it may receive it in HTML, XML, JSON, or something completely different. REST doesn’t mandate any specific data format—the way the data is stored on the server independently from these formats.
Moreover, the content and format of resource representation can also vary. For example, data may be expressed in English and encoded using UTF-8.
This separation between resources and their representations creates flexibility in delivering information to clients in various formats while keeping the underlying resources intact.
B) Manipulation of resources through representations
Resource manipulation through representations means that clients interact with resources by exchanging their representations. Instead of directly manipulating the underlying data, clients send requests that contain representations of the resource they want to modify.
For instance, when adding a new song to a music library, the client sends a POST
request with a representation of the song’s details, such as its title and artist, in the request’s body. Similarly, when updating an existing song, the client sends a PATCH
request with a representation containing the modified information.
This approach lets clients work with resources in a consistent and uniform manner, regardless of the specific implementation details on the server’s side. By manipulating representations instead of directly modifying the underlying resources, we simplify the interaction between the client and the server and promote interoperability between different systems and applications.
C) Self-descriptive Messages
In REST, each message exchanged between the client and server should contain enough information for the recipient to understand and process it, without relying on any external context.
This allows the client and server to communicate without relying on shared prior knowledge or external documentation. This promotes simplicity, interoperability, and ease of understanding, allowing developers to build systems that can seamlessly interact with each other.
D) Hypermedia as the engine of application state (HATEOAS)
Hypermedia as the engine of application state, or HATEOAS, emphasizes the use of hyperlinks within the API responses to drive the client’s behavior and application flow.
It’s like a roadmap provided by the server to guide the client through the available actions.
When you use a social media app, each post has links to perform actions like liking, commenting, or sharing. With HATEOAS, the server includes these hyperlinks in the response, allowing the client to perform specific actions by following the provided links.
This approach makes the client more autonomous and reduces the dependency on prior knowledge or fixed API endpoints. The server acts as a guide, providing necessary information and options dynamically, enabling clients to explore and interact with the API more intuitively. HATEOAS promotes loose coupling between the client and server, making the API more flexible and extensible.
2) Statelessness
The statelessness principle requires the server to retain no information about the client’s previous requests. In other words, the server treats each request sent by the client independently, without relying on any past interactions. The client has to include all necessary information in each request for the server to understand and process it.
Statelessness improves scalability, as the server can handle multiple clients at once, without having to manage and track their individual states. When an application uses load-balancing, clients can seamlessly switch from one server to another. Since every request contains all the required information, any server can respond to it.
Statelessness simplifies the system architecture, promotes modularity, and improves reliability.
3) Cacheability
Cacheability refers to a client’s ability to store and reuse server’s responses.
When a client makes a request to a server, the server includes information in the response indicating whether the response can be cached or not. If a response is cacheable, the client can store it locally and reuse it in subsequent requests instead of contacting the server again. This improves performance and reduces the load on the server, as the latter doesn’t need to process the same request repeatedly.
Cacheability is achieved by using appropriate caching headers in the HTTP response, such as Cache-Control
and Expires
. Although not all responses are cacheable—some may contain sensitive or dynamically computed content—caching enables faster and more efficient interactions between clients and servers.
4) Client-server design
In REST, the client and the server have specific and distinct roles. The client initiates the communication by sending requests to the server, specifying the desired action and resource to operate on. The server, in turn, processes the request, performs the necessary operations, generates a response, and sends the response back to the client.
This principle facilitates separation of concerns. The client is responsible for the user interface and user experience, while the server handles data storage, processing, and business logic.
This allows systems to be scalable and distributed: different clients can interact with the same server independently, and the same server can serve multiple clients simultaneously.
5) Layered system
The layered system architecture refers to the organization of components into layers, each with a specific responsibility. This helps us achieve a modular and scalable design.
Each layer performs a specific function and interacts only with the layer directly below or above it. For example, we may deploy web service APIs on Server A, store the data on Server B, and authenticate clients with Server C. In this way a client that connects to Server A cannot tell if its request is being processed on Server A or somewhere else. Server A just acts as an intermediary.
Layering promotes separation of concerns, making it easier to understand, modify, and maintain different parts of the system independently. It also improves flexibility by making it possible to add or modify layers without affecting other components.
The layered system architecture is a fundamental principle in RESTful design that promotes modularity and interoperability.
6) Code-on-demand
The code-on-demand principle allows the server to respond to clients with responses that contain executable code. This feature is optional and can enhance the functionality of the client.
The code can be in various forms, and it depends on the client’s capabilities. For instance, in a web application, JavaScript would be used. This allows for a more flexible and dynamic client experience.
However, the code-on-demand feature is rarely used in traditional REST APIs, since it introduces considerable security and maintainability overhead.
An example web service
As an example, let’s make a simple RESTful API to manage tasks. The app will allow clients to list and query to-do items, create new and update existing ones, and delete them if they are authorized.
We will use Python’s FastAPI framework to develop the web application, SQLite database to persist data, SQLAlchemy to connect to the database and write queries, Pydantic to validate data, and Uvicorn to run the web application.
We begin by describing how the dependencies get installed, how the database is structured and accessed, how the data is validated, and how the application is run. Once we’ve figured that out, we can discuss how to implement the API endpoints in a RESTful manner.
Python dependencies
Install the required dependencies with pip
.
$ pip install fastapi==0.98.0 pydantic==1.10.9 SQLAlchemy==2.0.17 uvicorn==0.22.0 httpie==3.2.2
Database setup
We will use SQLite database, an in-file database that requires no setup. We will use SQLAlchemy to set up the data model and to set up the database connection. The file models.py
and class Todo
specify the datatype and its database attributes.
from sqlalchemy import Boolean, Column, DateTime, Integer, String
from database import Base
class Todo(Base):
__tablename__ = "items"
id = Column(Integer, primary_key=True, index=True)
timestamp = Column(DateTime, index=True)
name = Column(String)
completed = Column(Boolean, index=True)
Table items
store Todo items. Each entry has four attributes: id, timestamp, name
and completed
.
Next, file database.py
specifies the database connection settings.
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
SQLALCHEMY_DATABASE_URL = "sqlite:///./todo-items.db"
engine = create_engine(
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
# Dependency
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
While we won’t discuss every possible configuration option (which you can find in FastAPI docs), two are worth mentioning:
- the database is stored in file
todo-items.db
which will be created when the application is first run, and - the function
get_db()
implements the database connection: when called it returns an instance representing an active connection, and when the instance goes out of scope, the connection gets closed.
Data validation
To validate both the HTTP request parameters and the data that comes from the database, we use Pydantic. All we have to do is specify appropriate Python classes.
Our application should validate HTTP request parameters when creating or updating items, and similarly, when returning items from the database. The following classes specify which fields are required in those cases.
from datetime import datetime
from pydantic import BaseModel, Field
class TodoCreate(BaseModel):
timestamp: datetime = Field(default_factory=datetime.now)
name: str
completed: bool = False
class TodoUpdate(BaseModel):
timestamp: datetime = None
name: str = None
completed: bool = None
class Todo(TodoCreate):
id: int
class Config:
orm_mode = True
Class TodoCreate
requires a new item containing the name
field while fields timestamp
and completed
are optional. When optional fields are omitted, they get set to current time and constant False
respectively.
Class TodoUpdate
specifies that during an update operation fields timestamp
, name
and completed
may be present, but none of them are required. For instance, an HTTP request that updates an item may only contain field completed
.
Class Todo
is used to validate and map database records into JSON messages. It extends the TodoCreate
class and additionally requires every item to have a field id
.
The RESTful API
Finally, we can present the crux of the web application. The app is compiled in the file main.py
. First, all dependencies are loaded, and the application is instantiated.
from fastapi import Depends, FastAPI, HTTPException, Response
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from starlette import status
import models
import schemas
from database import engine, get_db
models.Base.metadata.create_all(bind=engine)
app = FastAPI()
Running the web application
The file main.py
is a valid web application which can be run with uvicorn
.
$ uvicorn main:app --reload
The uvicorn
command invokes the web server, main:app
denotes the name of the python module (main.py
) and the application (app
), and the --reload
flag forces a web server to restart whenever the python code changes.
Creating items
The application doesn’t do much yet. Let’s implement the functionality for adding to-do items by appending the following function to the end of main.py
.
@app.post("/items", status_code=status.HTTP_201_CREATED, response_model=schemas.Todo)
def create_item(item: schemas.TodoCreate, response: Response,
db: Session = Depends(get_db)):
new_todo = models.Todo(timestamp=item.timestamp,
name=item.name,
completed=item.completed)
db.add(new_todo)
db.commit()
db.refresh(new_todo)
response.headers["location"] = f"http://localhost:8080/items/{new_todo.id}"
return new_todo
The function implements a RESTful endpoint that takes in an HTTP POST
request and persists the data to the database.
The endpoint expects a POST
request to URL /items
where the body of the request contains a JSON message that has fields name
, datetime
, and completed
. While name
is mandatory, the other two are optional.
If such a request is received, the resource is saved to the database, and a response is returned to the client denoting the newly created resource. For instance, let’s send an HTTP request with HTTPie.
$ http -v POST http://localhost:8000/items name="Hello World! This is a TODO!"
POST /items HTTP/1.1
Accept: application/json, */*;q=0.5
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Length: 40
Content-Type: application/json
Host: localhost:8000
User-Agent: HTTPie/2.6.0
{
"name": "Hello World! This is a TODO!"
}
HTTP/1.1 201 Created
content-length: 105
content-type: application/json
date: Tue, 27 Jun 2023 11:54:00 GMT
location: http://localhost:8080/items/1
server: uvicorn
{
"completed": false,
"id": 1,
"name": "Hello World! This is a TODO!",
"timestamp": "2023-06-27T13:54:00.804467"
}
Here we see a few REST principles in action:
- Uniform interface:
- The resources, to-do items, are identified with URLs:
http://localhost:8080/items
. - We manipulate the resources by sending their desired representation: we sent a representation of a new to-do item to the server, and it persisted it.
- The response body contains JSON representations of the newly created resource, which is different than how items are stored in the database.
- Messages are self-descriptive and self-contained.
- HATEOAS:
- The server returns response code
201 Create
, which denotes that a new resource has been created. - The response headers contain a
location
field denoting the URL of newly created resource. Note that we implemented this explicitly.
- The server returns response code
- The resources, to-do items, are identified with URLs:
- Statelessness: all the required information is contained within the request.
- Client-server design: a given since we’re using HTTP.
Now, let’s send an HTTP request that lacks required parameters.
$ http -v POST http://localhost:8000/items
POST /items HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Length: 0
Host: localhost:8000
User-Agent: HTTPie/2.6.0
HTTP/1.1 422 Unprocessable Entity
content-length: 81
content-type: application/json
date: Tue, 27 Jun 2023 11:57:58 GMT
server: uvicorn
{
"detail": [
{
"loc": [
"body"
],
"msg": "field required",
"type": "value_error.missing"
}
]
}
Now the API tells us that something is wrong:
- The response code is set to
422 Unprocessable Entity
: this means that the syntax of the request body is correct, but the contents are not. - The response body contains a JSON-formatted message telling us that the body is missing a required field. This error was generated automatically by Pydantic by using the
TodoCreate
class.
Let’s create a few more to-do items. You may set the completed
and datetime
fields manually.
$ http -v POST http://localhost:8000/items name="Await New Year!" completed=true datetime="2022-12-31T23:59:59"
$ http -v POST http://localhost:8000/items name="Another task"
$ http -v POST http://localhost:8000/items name="And another more"
Getting items
To get items from the database, we implement two more endpoints: the first returns a specific Todo item, the other list of all items. Let’s append these two functions to main.py
.
@app.get("/items/{item_id}", response_model=schemas.Todo)
def read_item(item_id: int, db: Session = Depends(get_db)):
todo = db.query(models.Todo).get(item_id)
if not todo:
raise HTTPException(status_code=404, detail=f"Todo with id={item_id} not found")
return todo
@app.get("/items", response_model=list[schemas.Todo])
def read_items(skip: int = 0, limit: int = 10, db: Session = Depends(get_db)):
return db.query(models.Todo).offset(skip).limit(limit).all()
Function read_item(id)
is invoked when sending a GET
request to a specific resource, like http://localhost:8000/items/1
. It reads the id from the URL, makes a look-up to the database, and then returns its JSON representation to the client. If the ID is incorrect, we return a 404 Not Found
response code. This is HATEOAS in practice. Here are two examples:
$ http -v http://localhost:8000/items/2
GET /items/2 HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Host: localhost:8000
User-Agent: HTTPie/2.6.0
HTTP/1.1 200 OK
content-length: 94
content-type: application/json
date: Wed, 28 Jun 2023 06:00:45 GMT
server: uvicorn
{
"completed": true,
"id": 2,
"name": "Wait for New Year!",
"timestamp": "2023-06-28T07:58:51.505889"
}
$ http -v http://localhost:8000/items/321
GET /items/321 HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Host: localhost:8000
User-Agent: HTTPie/2.6.0
HTTP/1.1 404 Not Found
content-length: 39
content-type: application/json
date: Wed, 28 Jun 2023 06:02:02 GMT
server: uvicorn
{
"detail": "Todo with id=321 not found"
}
Function read_items()
is invoked when sending a GET
request to http://localhost:8000/items
. It returns the list of all items in the database as JSON. By default, it returns the first 10 items. You can change this by manipulating the skip
and limit
query parameters, as shown here:
$ http -v http://localhost:8000/items
GET /items HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Host: localhost:8000
User-Agent: HTTPie/2.6.0
HTTP/1.1 200 OK
content-length: 386
content-type: application/json
date: Wed, 28 Jun 2023 06:03:18 GMT
server: uvicorn
[
{
"completed": false,
"id": 1,
"name": "Hello World! This is a TODO!",
"timestamp": "2023-06-28T07:58:42.767225"
},
{
"completed": true,
"id": 2,
"name": "Wait for New Year!",
"timestamp": "2023-06-28T07:58:51.505889"
},
{
"completed": false,
"id": 3,
"name": "Another task",
"timestamp": "2023-06-28T07:59:12.807956"
},
{
"completed": false,
"id": 4,
"name": "And another more",
"timestamp": "2023-06-28T07:59:34.804336"
}
]
$ http -v "http://localhost:8000/items?skip=1&limit=2"
GET /items?skip=1&limit=2 HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Host: localhost:8000
User-Agent: HTTPie/2.6.0
HTTP/1.1 200 OK
content-length: 186
content-type: application/json
date: Wed, 28 Jun 2023 06:04:05 GMT
server: uvicorn
[
{
"completed": true,
"id": 2,
"name": "Wait for New Year!",
"timestamp": "2023-06-28T07:58:51.505889"
},
{
"completed": false,
"id": 3,
"name": "Another task",
"timestamp": "2023-06-28T07:59:12.807956"
}
]
Updating items
To update an item, for instance to mark it completed, we implement the following endpoint in function update_item()
:
@app.patch("/items/{item_id}")
def update_item(item_id: int, todo_item: schemas.TodoUpdate,
db: Session = Depends(get_db)):
existing_todo = db.query(models.Todo).get(item_id)
if not existing_todo:
raise HTTPException(status_code=404, detail=f"Todo with id={item_id} not found")
if todo_item.timestamp is not None:
existing_todo.timestamp = todo_item.timestamp
if todo_item.name is not None:
existing_todo.name = todo_item.name
if todo_item.completed is not None:
existing_todo.completed = todo_item.completed
db.commit()
db.refresh(existing_todo)
return existing_todo
The endpoint runs when a PATCH
request is sent to a specific resource. In REST, updates are done either with PUT
or PATCH
. The difference is that PATCH
should be used for partial updates, that is updates when we update a subset of a resource’s attributes, while PUT
should be used when updating the entire resource.
The endpoint checks whether the resource that is being update exists. If not, it returns the 404 Not Found
response code.
If the item is present, it updates those attributes that are present in the request, commits changes to the database, and returns the updated resources in JSON. Let’s test it by marking the first to-do item as completed.
$ http -v PATCH http://localhost:8000/items/1 completed=true
PATCH /items/1 HTTP/1.1
Accept: application/json, */*;q=0.5
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Length: 21
Content-Type: application/json
Host: localhost:8000
User-Agent: HTTPie/2.6.0
{
"completed": "true"
}
HTTP/1.1 200 OK
content-length: 104
content-type: application/json
date: Wed, 28 Jun 2023 06:13:06 GMT
server: uvicorn
{
"completed": true,
"id": 1,
"name": "Hello World! This is a TODO!",
"timestamp": "2023-06-28T07:58:42.767225"
}
Deleting items
Finally, let’s make it possible to delete items. In this example, we will also add authorization: only those with sufficient privileges should be able to delete to-do items. This will also allow us to demonstrate how to authenticate requests without violating the statelessness principle.
Deletions are done with DELETE
request to a specific resource. However, to add authorization, we need to add two more things to the code. The entire addition is the following:
@app.delete("/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_item(item_id: int, db: Session = Depends(get_db), token: str = Depends(verify_token)):
todo = db.query(models.Todo).get(item_id)
if not todo:
raise HTTPException(status_code=404, detail=f"Todo with id={item_id} not found")
db.delete(todo)
db.commit()
security = HTTPBearer()
def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)):
token = credentials.credentials
# Replace the validation logic below with your own implementation
if token != "secret-password":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
headers={"WWW-Authenticate": "Bearer"},
)
return token
The endpoint delete_item()
is rather straight-forward: when a DELTE
request is received, it deletes the item from the database and returns response code 204 No content
. If provided id
is incorrect, a 404 Not Found
is returned.
However, these actions only execute if the client has proper authorization. This is handled by the verify_token()
function, which delete_item()
invokes implicitly through a FastAPI idiom called Dependency
.
The method verify_token
simply reads the Authorization
request header and if its value is Bearer secret-password
, execution is allowed. If not, a 401 Unauthorized
status code is returned. If the Authorization
field is missing, a 404 Forbidden
status code is returned. Let’s test it.
$ http -v DELETE http://localhost:8000/items/1
DELETE /items/1 HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Length: 0
Host: localhost:8000
User-Agent: HTTPie/2.6.0
HTTP/1.1 403 Forbidden
content-length: 30
content-type: application/json
date: Wed, 28 Jun 2023 06:25:43 GMT
server: uvicorn
{
"detail": "Not authenticated"
}
$ http -v -A bearer -a wrong-password DELETE http://localhost:8000/items/1
DELETE /items/1 HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Authorization: Bearer wrong-password
Connection: keep-alive
Content-Length: 0
Host: localhost:8000
User-Agent: HTTPie/3.2.2
HTTP/1.1 401 Unauthorized
content-length: 26
content-type: application/json
date: Wed, 28 Jun 2023 06:31:11 GMT
server: uvicorn
www-authenticate: Bearer
{
"detail": "Invalid token"
}
$ http -v -A bearer -a secret-password DELETE http://localhost:8000/items/1
DELETE /items/1 HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Authorization: Bearer secret-password
Connection: keep-alive
Content-Length: 0
Host: localhost:8000
User-Agent: HTTPie/3.2.2
HTTP/1.1 204 No Content
content-type: application/json
date: Wed, 28 Jun 2023 06:33:32 GMT
server: uvicorn
This kind of authorization is common in RESTful APIs, since it lets the server remain stateless. Typically, the token would not be a simple string like secret-password
but a more involved value like a JSON Web Token (JWT) that contains cryptographically signed claims about the client’s identity and its authorization level. The client needs to obtain such a token before invoking secured endpoint.
Such a token-based approach is different from what is typically used in web applications where a server-side session stores ephemeral client information, such as identity and authorization level. However, since a server-side session requires a stateful server, this would violate the statelessness principle of REST.
This concludes our RESTful API example. By following RESTful principles, we can create well-structured, scalable, and interoperable web services and APIs that are easier to develop, maintain, and integrate into various systems.