HTTP APIs
Table of contents
- Introduction
- Answering errors
- Paging a list
- Caching an answer
- Accepting long work
- Deprecating an API
- Limiting calls
- Reference
- Advanced
- See also
Introduction
A feature that serves an HTTP API answers the way every other API of the product does. Errors are application/problem+json, lists are paged by cursor, and caching, long work and deprecation each take one helper. You write the resources and the framework gives them a common shape.
from flask import jsonify
from splent_framework.api import Problem
@notes_api.route("/api/v2/notes/<int:note_id>")
def note(note_id):
note = notes_service.get(note_id)
if note is None:
raise Problem(404, "note_not_found", "No note with that id.")
return jsonify(id=note.id, title=note.title)
A client that asks for a note that is not there gets this.
{
"type": "about:blank",
"title": "Not found",
"status": 404,
"code": "note_not_found",
"detail": "No note with that id.",
"instance": "/api/v2/notes/7"
}
Answering errors
Every error under an API prefix is problem+json, also the ones you never raise. An address nobody serves is a 404, a method the route does not take is a 405 with Allow, and a failure nobody caught is a 500 that never shows the exception’s text.
Problem a view raises skips these questions and is always problem+json.abort works too. Words you give it become the detail.
abort(409, "The note is published.")
Naming the fields that are wrong
raise Problem(
422,
"invalid_note",
"The note has errors.",
errors={"title": "Too long."},
)
errors goes into the answer as it is. headers adds headers to it, such as Retry-After or WWW-Authenticate.
Choosing the API prefixes
class ProductionConfig(BaseProd):
SPLENT_API_PREFIXES = ("/api/", "/oai")
Set SPLENT_API_PREFIXES in your product’s config.py when an API lives outside /api/. A comma separated string works too. Every other page keeps the app’s HTML error pages.
Keeping an older API’s error format
legacy = Blueprint("legacy_api", __name__, url_prefix="/api/v1")
@legacy.errorhandler(404)
def not_found(error):
return jsonify(error="not found"), 404
An error handler of the request’s own blueprint still has the last word. An API that already has clients keeps its format this way.
Paging a list
from flask import request
from splent_framework.api import page_answer, paginate, read_limit
@notes_api.route("/api/v2/notes")
def notes():
page = paginate(
Note.query,
[(Note.created_at, "desc"), (Note.id, "asc")],
read_limit(),
request.args.get("cursor"),
)
return page_answer([{"id": n.id, "title": n.title} for n in page.items], page)
{
"data": [{"id": 12, "title": "Groceries"}],
"page": {"limit": 20},
"links": {
"self": "https://example.org/api/v2/notes",
"next": "https://example.org/api/v2/notes?cursor=WyIyMDI2LTEwLTMwIiwxMl0"
}
}
Pages go by cursor, not by number. Rows that come in meanwhile never shift a page, and the thousandth page costs what the first one does.
The order must end in a unique column, such as the primary key, and none of its columns may be null. Otherwise rows can move between pages.
read_limit() reads limit from the request, 20 by default and at most 100. Anything else answers 400 with errors.limit. page_answer puts the same links in the Link header, and adds first once the client is past the first page.
Pass total=True to paginate to add page.total. It costs a second query, so ask for it only when you need it.
Caching an answer
from splent_framework.api import conditional
@notes_api.route("/api/v2/notes/<int:note_id>")
def note(note_id):
note = notes_service.get_or_404(note_id)
return conditional(jsonify(id=note.id, title=note.title))
conditional adds a strong ETag and Cache-Control: public, max-age=60. A client that sends the same tag in If-None-Match gets 304 with no body.
Pass private=True for an answer that depends on who asks. It gets private, no-cache instead.
Accepting long work
from splent_framework.api import accepted
from splent_framework.tasks import enqueue
@notes_api.route("/api/v2/exports", methods=["POST"])
def start_export():
export = exports_service.create()
enqueue(build_export, {"export_id": export.id})
return accepted(f"/api/v2/exports/{export.id}", {"status": "pending"})
accepted answers 202 with Location and Retry-After, 5 seconds unless you pass retry_after.
Point Location at a resource whose state says how the work goes, never at a task run. Another worker of the app does not know the runs of this one. The task can report its progress for that resource to show.
Deprecating an API
from datetime import datetime
from splent_framework.api import deprecate
deprecate(
legacy,
since=datetime(2026, 11, 1),
sunset=datetime(2027, 6, 1),
successor="/api/v2/notes",
)
Every answer of the blueprint now carries Deprecation, Sunset and Link: </api/v2/notes>; rel="successor-version". A date without a zone is read as UTC.
Pass prefix="/api/v1/notes" to mark only the paths under it. You can call it on the app too.
Limiting calls
rate_limited counts the calls of a view per address and answers 429 with Retry-After. Under an API prefix that 429 is problem+json too. See Rate limits and forms.
Reference
Problem(status, code, detail="", *, title=None, errors=None, headers=None)
| Status | title |
code the framework gives |
|---|---|---|
| 400 | Bad request | bad_request |
| 401 | Not authenticated | not_authenticated |
| 403 | Not allowed | forbidden |
| 404 | Not found | not_found |
| 405 | Method not allowed | method_not_allowed |
| 406 | Not acceptable | not_acceptable |
| 409 | Conflict | conflict |
| 410 | Gone | gone |
| 412 | Precondition failed | precondition_failed |
| 413 | Too large | too_large |
| 415 | Unsupported media type | unsupported_media_type |
| 422 | Unprocessable | unprocessable |
| 423 | Locked | locked |
| 428 | Precondition required | precondition_required |
| 429 | Too many requests | too_many_requests |
| 500 | Internal error | internal_error |
| 502 | Bad gateway | bad_gateway |
| 503 | Unavailable | unavailable |
Any other status gets the title Error and the code http_<status>. A Problem you raise keeps the code you gave it.
| Helper | Signature |
|---|---|
paginate |
paginate(query, order, limit, cursor=None, *, total=False), a Page with items, limit, next_cursor and total |
read_limit |
read_limit(args=None, default=20, most=100) |
page_answer |
page_answer(data, page, **extra), where extra goes into the body next to data |
conditional |
conditional(response, private=False) |
accepted |
accepted(location, body=None, retry_after=5) |
deprecate |
deprecate(blueprint_or_app, *, since, sunset, successor, prefix="") |
| Setting | Default |
|---|---|
SPLENT_API_PREFIXES |
("/api/",) |
Advanced
A
Problemraised in any view answers problem+json, inside the API prefixes or not.
With
PROPAGATE_EXCEPTIONSon, or left unset under testing or debug, an uncaught exception is raised again instead of becoming a 500, so your test sees the traceback.
See also
- Rate limits and forms
- Background tasks, for the work behind a 202.