REST API¶
flask-peewee comes with some tools for exposing your project’s models via a
REST API. There are several components to the rest module, but the basic
setup is to create an instance of RestAPI and then register your
project’s models with subclasses of RestResource.
Each RestResource you expose via the API will support, by default,
the following:
/api/<model name>/: GET and POST requests/api/<model name>/<primary key>/: GET, PUT, PATCH and DELETE requests. POST also edits, andPOST /<primary key>/delete/works as DELETE, for clients that cannot issue PUT or DELETE.
PUT and PATCH share partial-update semantics, changing only the fields present in the request body.
Also, you can filter results by columns on the model using django-style syntax, for example:
/api/blog/?name=Some%20Blog/api/blog/?author__username=some_blogger
Full operations:
__eq: equals__lt: less-than__lte: less-than or equal to__gt: greater-than__gte: greater-than or equal to__ne: not equal to__in: in set__not_in: not in set__is: is,?field__is=Noneor?-field__is=Nonefor NOT NULL__is_not: is not,?field__is_not=None__is_null: takes true/false,?field__is_null=truefor IS NULL__like: wild-card matching, case-sensitive__ilike: wild-card matching, case-insensitive__contains: substring match, case-insensitive__startswith,__endswith: prefix / suffix match, case-insensitive__regexp: regular-expression matching (database-specific)__iregexp: likeregexpbut case-insensitive__between: two comma-separated bounds, inclusive,?id__between=2,5
To negate an operation, prefix it with the - character, e.g. the following
are equivalent:
/api/user/?admin=true/api/user/?admin__eq=true/api/user/?-admin=false/api/user/?admin__ne=false
Special Python constants are supported when used as querystring parameters:
?value=nonetranslates the value toNone?value=truetranslates the value toTrue?value=falsetranslates the value toFalse
Getting started with the API¶
In this documentation we’ll start with a very simple API and build it out. The complete version of this API is included in the example app, so feel free to refer there.
The project will be a simple ‘twitter-like’ app where users can post short messages and “follow” other users.
Project models¶
There are three main models, User, Relationship and Message, which
we will expose via the API. Here is a truncated version of what they look like:
from flask_peewee.auth import BaseUser
class User(db.Model, BaseUser):
username = CharField()
password = CharField()
email = CharField()
join_date = DateTimeField(default=datetime.datetime.now)
active = BooleanField(default=True)
admin = BooleanField(default=False)
class Relationship(db.Model):
from_user = ForeignKeyField(User, backref='relationships')
to_user = ForeignKeyField(User, backref='related_to')
class Message(db.Model):
user = ForeignKeyField(User)
content = TextField()
pub_date = DateTimeField(default=datetime.datetime.now)
Creating a RestAPI¶
The RestAPI holds the RestResource objects we will
expose. By default it binds all resources to /api/<model-name>/.
Here we’ll create a simple api and register our models:
from flask_peewee.rest import RestAPI
from app import app # our project's Flask app
# instantiate our api wrapper
api = RestAPI(app)
# register our models so they are exposed via /api/<model>/
api.register(User)
api.register(Relationship)
api.register(Message)
# configure the urls
api.setup()
Now if we hit our project at /api/message/ we should get something like the following:
{
"meta": {
"model": "message",
"next": "",
"object_count": 2,
"page_count": 1,
"page": 1,
"previous": ""
},
"objects": [
{
"content": "flask and peewee, together at last!",
"pub_date": "2026-09-16T18:36:15",
"user": 1,
"id": 1
},
{
"content": "Hey, I'm just some user",
"pub_date": "2026-09-16T18:46:59",
"user": 2,
"id": 2
}
]
}
Say we’re interested in the first message, we can hit /api/message/1/ to view
just the details on that object:
{
"content": "flask and peewee, together at last!",
"pub_date": "2026-09-16T18:36:15",
"user": 1,
"id": 1
}
Customizing what is returned¶
If you access the User API endpoint, we quickly notice a problem:
$ curl http://127.0.0.1:5000/api/user/
{
"meta": {
"model": "user",
"next": "",
"object_count": 2,
"page": 1,
"page_count": 1,
"previous": ""
},
"objects": [
{
"username": "admin",
"admin": true,
"email": "",
"join_date": "2026-09-16T18:34:49",
"active": true,
"password": "d033e22ae348aeb5660fc2140aec35850c4da997",
"id": 1
},
{
"username": "coleifer",
"admin": false,
"email": "coleifer@gmail.com",
"join_date": "2026-09-16T18:35:56",
"active": true,
"password": "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3",
"id": 2
}
]
}
Passwords and email addresses are being exposed. To exclude these fields
from serialization, subclass RestResource:
from flask_peewee.rest import RestAPI, RestResource
from app import app # our project's Flask app
# instantiate our api wrapper
api = RestAPI(app)
# create a special resource for users that excludes email and password
class UserResource(RestResource):
exclude = ('password', 'email',)
# register our models so they are exposed via /api/<model>/
api.register(User, UserResource) # specify the UserResource
api.register(Relationship)
api.register(Message)
Now emails and passwords are no longer returned by the API.
exclude is a blacklist. Its positive counterpart is fields, a
whitelist of the only fields to serialize. The resource above could instead expose just
the username and id:
class UserResource(RestResource):
fields = ('username', 'id')
Reach for whichever is more convenient: fields when you want to expose a
small, fixed set of columns, exclude when you want everything but a few.
For computed values, override prepare_data(). It
receives each object and its serialized dictionary on the way out:
class UserResource(RestResource):
exclude = ('password', 'email')
def prepare_data(self, obj, data):
data['gravatar'] = obj.gravatar_url()
return data
Nested resources¶
By default a foreign key is serialized as the related row’s primary key. Notice
the "user": 1 in the message output above. To embed the full related
object instead, point include_resources at the resource that should render it:
class UserResource(RestResource):
exclude = ('password', 'email',)
class MessageResource(RestResource):
include_resources = {'user': UserResource}
api.register(User, UserResource)
api.register(Message, MessageResource)
Now each message embeds its author, serialized through UserResource (so the
password and email are still excluded):
{
"content": "flask and peewee, together at last!",
"pub_date": "2026-09-16T18:36:15",
"user": {
"username": "admin",
"admin": true,
"active": true,
"join_date": "2026-09-16T18:34:49",
"id": 1
},
"id": 1
}
include_resources can be nested arbitrarily deep (an included resource may
itself include resources), and one model can be embedded through more than one
foreign key. For example, a Relationship resource can expand both endpoints:
class RelationshipResource(RestResource):
include_resources = {
'from_user': UserResource,
'to_user': UserResource,
}
The whole nested tree is loaded in a single query, one JOIN per included
foreign key, so embedding related objects does not incur the N+1 queries you
would get from following each row’s relations lazily.
Nested writes¶
Included resources also work on the way in. A POST or PUT whose body
carries a nested object (instead of a bare id) creates or updates the related row
as part of the same request. Two rules keep that safe:
A resource’s
readonly_fieldsare stripped at every level of the payload, so a nested object cannot smuggle in a field the resource protects (e.g. slipping"admin": trueinto a nested user).Each nested write must pass the child resource’s own
check_post/check_put, exactly as a direct write to that resource would, so nesting can never be used to sidestep a resource’s authorization.
The entire object graph is saved in a single transaction, so if any nested write
is rejected the whole request rolls back. To disable nested writes for a
resource, set nested_writes = False. A nested object in the payload is then
ignored, though the foreign key can still be assigned with a bare id.
Validating incoming data¶
A write accepts its payload three ways, checked in order: a JSON request
body (send Content-Type: application/json), a form field named
data holding a JSON string (the curl -d data='{...}' convention),
or plain form fields, one per column.
Write payloads are validated as they are deserialized, and problems surface as a 400 with a JSON error rather than a 500 (or worse, bad data):
A body that is not valid JSON, or whose JSON is not an object, is rejected.
Values that cannot be coerced to their field’s type are rejected. This includes date/time strings: a value like
"pub_date": "not-a-date"returns{"error": "Unrecognized date/time value for \"pub_date\": 'not-a-date'"}instead of being written through to the database. Both ISO-8601 (what the API itself emits) and the field’s ownformatsare accepted.Violated database constraints (
NOT NULL, unique, foreign keys) are reported as a 400 as well.
Unrecognized keys in a payload are silently ignored by default, which is
forgiving but means a typo’d field name is dropped without complaint. Set
reject_unknown_fields = True on the resource to get a 400 listing the
offending keys instead:
class MessageResource(RestResource):
reject_unknown_fields = True
$ curl -u admin:admin -H 'Content-Type: application/json' \
-d '{"contnet": "hello"}' http://127.0.0.1:5000/api/message/
{"error": "Unrecognized field(s): contnet"}
Read-only fields are exempt (they are stripped, not rejected), so fetching an
object and PUT-ing the whole thing back continues to work, and a foreign
key may be written by field name or column name (user / user_id).
Unknown keys inside a nested object are reported with the __ path notation,
e.g. user__usernmae.
Error responses¶
Every error the API returns is a JSON object with a single error key,
whether it is a 400, 401, 403, 404 or 405. A 401 also carries the
WWW-Authenticate challenge header.
$ curl http://127.0.0.1:5000/api/message/9999/
{"error": "Not found"}
Bulk creation¶
A POST body is normally a single JSON object. Set allow_bulk = True on a
resource and POST a JSON list to create many objects in one request:
class MessageResource(RestResource):
allow_bulk = True
$ curl -u admin:admin -H 'Content-Type: application/json' \
-d '[{"user": 1, "content": "one"}, {"user": 1, "content": "two"}]' \
http://127.0.0.1:5000/api/message/
Each object passes through the same validation as a single create, and the
whole batch is saved in one transaction. An object that fails returns a 400
naming its index and nothing is saved. A list longer than max_bulk
objects (default 100) is rejected. On success the response is
{"objects": [...]} with the created objects in order.
Allowing users to post objects¶
What if we want to create new messages via the Api? Or modify/delete existing messages?
$ curl -i -d '' http://127.0.0.1:5000/api/message/
HTTP/1.1 401 UNAUTHORIZED
WWW-Authenticate: Basic realm="Login Required"
Content-Type: application/json
{"error": "Authentication failed"}
The authentication failed because the default authentication mechanism only allows read-only access.
To allow users to create messages via the API, we need to use a subclass
of Authentication that allows POST requests. We also want to ensure
that the requesting user is a member of the site.
For this we will use the UserAuthentication class as the default auth
mechanism.
from auth import auth # import the Auth object used by our project
from flask_peewee.rest import RestAPI, RestResource, UserAuthentication
# create an instance of UserAuthentication
user_auth = UserAuthentication(auth)
# instantiate our api wrapper, specifying user_auth as the default
api = RestAPI(app, default_auth=user_auth)
# create a special resource for users that excludes email and password
class UserResource(RestResource):
exclude = ('password', 'email',)
# register our models so they are exposed via /api/<model>/
api.register(User, UserResource) # specify the UserResource
api.register(Relationship)
api.register(Message)
# configure the urls
api.setup()
Now we should be able to POST new messages.
import requests
# authenticate with HTTP basic auth
resp = requests.post(
'http://localhost:5000/api/message/',
json={'user': 1, 'content': 'hello api'},
auth=('admin', 'admin'),
)
response = resp.json()
The response object will look something like this:
{
'content': 'hello api',
'user': 1,
'pub_date': '2026-09-22T11:25:02',
'id': 3
}
There is a problem with this, however. Notice how the user was passed in
with the POST data? This effectively will let a user post a message as another user.
It also means a user can use PUT requests to modify another user’s message:
# continued from above, edit another user's message (id=2)
resp = requests.put(
'http://127.0.0.1:5000/api/message/2/',
json={'content': 'haxed you, bro'},
auth=('admin', 'admin'),
)
response = resp.json()
The response will look like this:
{
'content': 'haxed you, bro',
'pub_date': '2026-09-16T18:36:15',
'user': 2,
'id': 2
}
This is a problem. We need a way of ensuring that users can only edit their own messages. Furthermore, when they create messages we need to make sure the message is assigned to them.
Restricting API access on a per-model basis¶
flask-peewee comes with a special subclass of RestResource that
restricts POST/PUT/PATCH/DELETE requests to prevent users from modifying another user’s
content.
from flask_peewee.rest import RestrictOwnerResource
class MessageResource(RestrictOwnerResource):
owner_field = 'user'
api.register(Message, MessageResource)
Now, if we try and modify the message, we get a 403 Forbidden:
resp = requests.put(
'http://127.0.0.1:5000/api/message/2/',
json={'content': 'haxed you, bro'},
auth=('admin', 'admin'),
)
print(resp.status_code) # 403
It is fine to modify our own message, though (message with id=1):
resp = requests.put(
'http://127.0.0.1:5000/api/message/1/',
json={'content': 'haxed you, bro'},
auth=('admin', 'admin'),
)
print(resp.status_code) # 200
Under-the-hood, the implementation of the RestrictOwnerResource is pretty simple.
PUT / PATCH / DELETE: verify the authenticated user is the owner of the object
POST: assign the authenticated user as the owner of the new object
Locking down a resource¶
Suppose we want to restrict normal users from modifying User resources. For this
we can use a special subclass of UserAuthentication that restricts access
to administrators:
from flask_peewee.rest import AdminAuthentication
# instantiate our user-based auth
user_auth = UserAuthentication(auth)
# instantiate admin-only auth
admin_auth = AdminAuthentication(auth)
# instantiate our api wrapper, specifying user_auth as the default
api = RestAPI(app, default_auth=user_auth)
# register the UserResource with admin auth
api.register(User, UserResource, auth=admin_auth)
Adding custom endpoints¶
A resource’s urls come from its get_urls() method. Extend it to
expose views alongside the standard list and detail. Here messages gain
/api/message/mine/, the authenticated user’s messages. Reads need an
authentication that sets g.user, so protect GET too:
from flask import g
from flask_peewee.rest import ALL_METHODS
user_auth = UserAuthentication(auth, protected_methods=ALL_METHODS)
class MessageResource(RestrictOwnerResource):
owner_field = 'user'
def get_urls(self):
return super(MessageResource, self).get_urls() + (
('/mine/', self.require_method(self.api_mine, ['GET'])),
)
def api_mine(self):
query = self.get_query().where(Message.user == g.user)
return self.paginated_object_list(query)
api.register(Message, MessageResource, auth=user_auth)
Token-based authentication¶
UserAuthentication and AdminAuthentication use HTTP
Basic auth, which is handy for humans but awkward for programmatic clients. For
API clients, flask-peewee ships token-based authentication classes. Like all
authentication classes they only guard the protected_methods (POST,
PUT, PATCH and DELETE by default, with GET open). To require
auth on reads too, pass protected_methods=ALL_METHODS (a convenience
constant equal to ('GET', 'POST', 'PUT', 'PATCH', 'DELETE')) or your own
list.
API keys¶
APIKeyAuthentication authenticates against a model with key and
secret fields, supplied as query-string, header, or form parameters. The
matched row is stored on g.api_key:
from flask_peewee.rest import APIKeyAuthentication
class APIKey(db.Model):
key = CharField()
secret = CharField()
api_key_auth = APIKeyAuthentication(APIKey)
api.register(SecretModel, auth=api_key_auth)
# curl "http://127.0.0.1:5000/api/secretmodel/?key=abc&secret=xyz"
Warning
Because the key and secret can travel in the query string, they may end up in access logs. Prefer bearer tokens (below) for anything sensitive.
Bearer tokens¶
HashedBearerAuthentication reads a token from the standard
Authorization: Bearer <token> header, keeping the credential out of the
query string and logs. Tokens are stored as sha256 hashes, so the raw token
never touches the database. make_token_model() builds the token
model, and its create_token() classmethod returns the new row along with
the raw token:
from flask_peewee.rest import HashedBearerAuthentication
from flask_peewee.rest import make_token_model
ApiToken = make_token_model(db, user_model=User)
token, raw = ApiToken.create_token(user=some_user)
# show "raw" to the caller once. it cannot be recovered later.
api.register(SecretModel, auth=HashedBearerAuthentication(ApiToken))
# curl -H "Authorization: Bearer <raw>" http://127.0.0.1:5000/api/secretmodel/
The model has token_hash, created, expires and revoked
columns, plus a user foreign key when user_model is given. A token
stops working when revoked is set or expires passes (null means no
expiry):
ApiToken.create_token(user=some_user,
expires=datetime.now() + timedelta(days=30))
The matching row is stored on g.api_key. When the model has a user
foreign key, g.user is set to the token’s user, so bearer tokens work with
RestrictOwnerResource and anything else keyed off the
authenticated user: new objects are assigned to the token’s user, and they may
only modify their own. Omit user_model for tokens tied to no user.
Custom token schemes¶
When the factory model does not fit, plain BearerAuthentication
looks the presented token up verbatim in a model with a token field
(override token_field to rename it, or get_key to change the lookup)
and sets g.api_key. UserBearerAuthentication extends it to
resolve the row to a user through its user_field foreign key and set
g.user instead of g.api_key. Set user_field = None when the token
lives on the user model itself.
Filtering records and querying¶
A REST Api is not very useful if it cannot be queried in a meaningful fashion. To
this end, the flask-peewee RestResource objects support “django-style”
filtering:
$ curl http://127.0.0.1:5000/api/message/?user=2
This call will return only messages by the User with id=2:
{
"meta": {
"model": "message",
"next": "",
"object_count": 1,
"page": 1,
"page_count": 1,
"previous": ""
},
"objects": [
{
"content": "haxed you, bro",
"pub_date": "2026-09-16T18:36:15",
"user": 2,
"id": 2
}
]
}
Joins can be traversed using the django double-underscore notation:
$ curl http://127.0.0.1:5000/api/message/?user__username=admin
{
"meta": {
"model": "message",
"next": "",
"object_count": 2,
"page": 1,
"page_count": 1,
"previous": ""
},
"objects": [
{
"content": "flask and peewee, together at last!",
"pub_date": "2026-09-16T18:36:15",
"user": 1,
"id": 1
},
{
"content": "hello api",
"pub_date": "2026-09-22T11:25:02",
"user": 1,
"id": 3
}
]
}
It is also supported to use different comparison operators with the same double-underscore notation:
$ curl http://127.0.0.1:5000/api/user/?id__lt=2
{
"meta": {
"model": "user",
"next": "",
"object_count": 1,
"page": 1,
"page_count": 1,
"previous": ""
},
"objects": [
{
"username": "admin",
"admin": true,
"join_date": "2026-09-16T18:34:49",
"active": true,
"id": 1
}
]
}
- Valid Comparison Operators are:
‘eq’, ‘lt’, ‘lte’, ‘gt’, ‘gte’, ‘ne’, ‘in’, ‘not_in’, ‘is’, ‘is_not’, ‘is_null’, ‘like’, ‘ilike’, ‘contains’, ‘startswith’, ‘endswith’, ‘regexp’, ‘iregexp’, ‘between’
The in and not_in operators accept a comma-separated list and/or
repeated parameters, so ?id__in=1,2 and ?id__in=1&id__in=2 are
equivalent. between takes exactly two comma-separated values.
A filter repeated with different values matches any of them, so
?username=a&username=b is an OR. Exclusions combine the other way, so
repeated ne or negated values exclude every listed value.
Note
Unrecognized filter parameters (a misspelled field, or a field not exposed
for filtering) are ignored by default, so a typo such as ?usernam=x
silently returns every row. The lenient default keeps stray query-string
parameters (cache-busters, tracking params) from breaking a request. Set
reject_unknown_filters = True on the resource to get a 400 naming the
offending parameters instead. Those stray parameters then 400 as well, so
enable it only when clients send clean query strings. An unknown
ordering column is always ignored.
Restricting what can be filtered¶
By default every field on the model is filterable, but related columns are
not. Since filters come straight off the query string, you will often want to
restrict this, especially for sensitive columns. Three
RestResource attributes control it:
filter_fields: a whitelist, only these fields may be filtered on. Related columns use__notation, e.g.user__username.filter_exclude: a blacklist of fields that may never be filtered on (use__notation for related columns, e.g.user__password).filter_recursive: set toTrueto make every column of a related model filterable, up tomax_filter_depthforeign keys deep.
Warning
A filter reveals the value of the column it tests, even when the column is
not serialized. ?user__password__startswith=a tests the password hash
one character at a time, and exclude does not prevent it. For that
reason filter_recursive is off by default, and a resource in
include_resources adds only the filters in its own filter_fields.
class MessageResource(RestResource):
# the only fields a client may filter on (with any operator)
filter_fields = ('id', 'content', 'user__username')
class UserResource(RestResource):
exclude = ('password',) # don't serialize the hash...
filter_exclude = ('password',) # ...and don't let it be filtered on either
With the default lenient handling (see the note above), tightening this list
never breaks an otherwise-valid request. A now-disallowed filter stops
narrowing the results. With reject_unknown_filters set it becomes a 400
instead.
Sorting results¶
Results can be sorted by specifying an ordering as a GET argument. The
ordering must be a column the resource allows filtering on. Anything else is
ignored.
/api/message/?ordering=pub_date
If you would like to order objects “descending”, place a “-” (hyphen character) before the column name:
/api/message/?ordering=-pub_date
Limiting results and pagination¶
By default, resources are paginated 20 per-page (the paginate_by attribute).
Specify a limit in the querystring to request a different page size, larger
or smaller:
/api/message/?limit=2
paginate_by is only the default page size, not a maximum. A client may
request a larger page. To cap how large a page can be requested, set
max_paginate_by on the resource (it defaults to None, meaning no ceiling).
Setting paginate_by = None disables pagination and returns every matching
object on a single page (still wrapped in the standard meta/objects
envelope).
In the “meta” section of the response, URIs for the “next” and “previous” sets of results are available, along with the total number of pages:
"meta": {
"model": "message",
"next": "/api/message/?limit=1&page=3",
"object_count": 5,
"page": 2,
"page_count": 5,
"previous": "/api/message/?limit=1&page=1"
}