HTTP APIs

Table of contents

  1. Introduction
  2. Answering errors
    1. Naming the fields that are wrong
    2. Choosing the API prefixes
    3. Keeping an older API’s error format
  3. Paging a list
  4. Caching an answer
  5. Accepting long work
  6. Deprecating an API
  7. Limiting calls
  8. Reference
  9. Advanced
  10. 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.

Which answer an error gets An error abort, a 404, a 405, an uncaught exception Under an API prefix? SPLENT_API_PREFIXES /api/ by default yes Its blueprint handles it? an error handler of the request's own blueprint no The app's HTML page for people yes Its own format no problem +json
A 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 Problem raised in any view answers problem+json, inside the API prefixes or not.

With PROPAGATE_EXCEPTIONS on, 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


Back to top

splent. Distributed by an LGPL license v3. Contact us: drorganvidez@us.es