API Overview¶
The HTTP surface over the workflow definition engine. A view picks a queryset,
declares permissions and throttles, calls one service, and serialises the
result; the rules themselves live in core/, not here.
Everything is under /api/v1/. The version is in the path rather than a header
so it is visible in a log line, a browser history and a bug report. When v2
exists it gets a new module and a new prefix, and v1 keeps working until it is
explicitly retired.
Authentication¶
Two mechanisms, because they fail differently:
| Mechanism | Header | Revocable | Use when |
|---|---|---|---|
| DRF token | Authorization: Token <key> |
Yes — delete the row | You need deactivation or revocation to take effect immediately |
| JWT | Authorization: Bearer <access> |
No — it expires | You want no per-request database lookup |
# A DRF token
curl -sX POST https://example.com/api/v1/auth/token/ \
-d 'username=alice&password=...' | jq -r .token
# A JWT pair
curl -sX POST https://example.com/api/v1/auth/jwt/ \
-H 'Content-Type: application/json' \
-d '{"username": "alice", "password": "..."}'
Both are checked against is_active as well as authentication. A deactivated
account still holds a valid token, so checking the flag is what makes
deactivation take effect now rather than at expiry.
| Endpoint | Method | Purpose |
|---|---|---|
/auth/token/ |
POST | Exchange username/password for a DRF token |
/auth/jwt/ |
POST | Exchange username/password for an access/refresh pair |
/auth/jwt/refresh/ |
POST | Exchange a refresh token for a new access token |
/auth/me/ |
GET | The authenticated caller |
Authorization¶
Every view names its permission class explicitly, and the default is authenticated-only, so a view that forgets cannot become public by accident.
| Rule | Applies to |
|---|---|
| Authenticated and active | /auth/me/, all run endpoints |
| Staff to write, anyone active to read | Definition endpoints |
| Owner or staff (object level) | Reading one run |
A run belonging to somebody else is 403, not 404: the row exists and the caller is simply not allowed to read it. Hiding it would be a second, contradictory policy layered on the first.
Endpoints¶
| Endpoint | Method | Success | Failure |
|---|---|---|---|
/workflows/ |
GET | 200 | 401 |
/workflows/ |
POST | 201 created, 200 already published | 400, 401, 403, 409 |
/workflows/{name}/{version}/ |
GET | 200 | 401, 404 |
/runs/ |
GET | 200 | 401 |
/runs/ |
POST | 202 | 400, 401, 404, 422, 429 |
/runs/{id}/ |
GET | 200 | 401, 403, 404 |
/runs/{id}/events/ |
GET | 200 | 401, 404 |
/task-runs/{id}/requeue/ |
POST | 200 | 401, 404, 409 |
/members/ |
GET, POST | 200, 201 | 401, 403, 409 |
/members/{id}/ |
PATCH, DELETE | 200, 204 | 401, 403, 404, 409 |
/api-keys/ |
GET, POST | 200, 201 | 401, 403 |
/api-keys/{id}/rotate/ |
POST | 200 | 401, 403, 404 |
/api-keys/{id}/revoke/ |
POST | 204 | 401, 403, 404 |
/metrics/ |
GET | 200 | — (unauthenticated, aggregate only) |
/schema/ |
GET | 200 | — |
/docs/ |
GET | 200 | — |
The request and response body of every one of these is in the API reference; this page covers the conventions they share.
Publishing a definition¶
Staff only. Publishing changes what every future run executes, so it is not an ordinary authenticated write.
curl -sX POST https://example.com/api/v1/workflows/ \
-H "Authorization: Token $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"name": "etl",
"version": 1,
"description": "Nightly extract and load",
"tasks": [
{"key": "extract", "task_type": "sql"},
{"key": "load", "task_type": "sql", "depends_on": ["extract"]}
]
}'
The body is validated by the JSON Schema in core.domain.schema, not by a
serializer — the same schema the parser and the persistence round trip use. A
shape violation is reported with a JSONPath ($.tasks[1].key), and a graph
violation (unknown dependency, duplicate key, cycle) names the task. Both are
400.
Publishing is idempotent by content: reposting the identical definition
returns 200 and the existing row, so a retry after a timeout is not an error.
Posting different content under an existing (name, version) is 409; bump
the version instead. 201 versus 200 is decided by the row that was actually
written, not by a pre-check that would race.
Triggering a run¶
curl -sX POST https://example.com/api/v1/runs/ \
-H "Authorization: Token $TOKEN" \
-H 'Content-Type: application/json' \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"name": "etl", "version": 1}'
202 Accepted, not 201: the run is accepted, not finished. Each task run is
created in its initial state — ready when it has no dependencies, pending
otherwise — and nothing claims them yet; that is Phase 4.
The Idempotency-Key header is optional. Its absence is not an error; it just
means a retry creates a second run. When present:
- a replay with the same body returns the original run with
Idempotent-Replay: true; - the same key with a different body is
422 idempotency_mismatch, because replaying the original response would silently discard the new request; - the key is scoped per user, so one user's key cannot suppress another's request.
The guarantee comes from a unique index on (user, key), not from a lock: a row
that does not exist yet cannot be locked, so two first-callers would both
proceed. The reservation shares the run's transaction, so a failure to create
the run releases the key rather than leaving a permanent hole that replays as an
empty response.
Errors¶
Every failure has the same shape, so a client writes its error handling once:
{
"error": {
"code": "validation_error",
"message": "1 problem(s) in workflow definition:\n - $.tasks: 'tasks' is a required property",
"details": {"version": ["This field is required."]}
}
}
details is omitted rather than set to null when there is nothing to add, so
a client can branch on its presence.
| Code | Status | Meaning |
|---|---|---|
validation_error |
400 | Malformed payload, invalid graph, or an invalid query value |
authentication_required |
401 | No credentials, or unusable credentials |
authentication_failed |
401 | Credentials presented and rejected |
permission_denied |
403 | Authenticated, not permitted |
not_found |
404 | Unknown definition or run |
method_not_allowed |
405 | Method not supported on that resource |
conflict |
409 | Different content under an existing (name, version) |
idempotency_mismatch |
422 | Key reused with a different body |
throttled |
429 | Rate limit exceeded |
server_error |
5xx | Unexpected; Django's own 500 path runs rather than a tidy envelope |
An exception the handler does not recognise returns None, so Django's normal
500 path runs. Dressing a defect up as a well-formatted client error would hide
it behind a response that looks intentional.
Pagination¶
Cursor-based, newest first, with the primary key as a tiebreaker — created_at
alone is not unique, and a cursor landing in the middle of a group of equal
values duplicates a row. Offset paging over these append-heavy tables would skip
or repeat rows whenever something is created between two requests.
{"next": "https://example.com/api/v1/runs/?cursor=...", "previous": null, "results": []}
?page_size= is capped at 100 so a list endpoint cannot be used to exhaust the
database connection's memory.
Filtering¶
| Endpoint | Parameters |
|---|---|
/workflows/ |
name (exact), name__startswith, version |
/runs/ |
state, workflow (definition name), created_after, created_before |
An unknown parameter is ignored by the filter backend, which is why the allowed
set is declared in api/filters.py rather than read ad hoc from the request.
Rate limiting¶
Per-user, per-scope:
| Scope | Rate | Applied to |
|---|---|---|
auth |
10/min | Token and JWT issuance |
trigger |
60/min | Publishing a definition, triggering a run |
read |
300/min | Everything else |
A scope without a configured rate is a 500 on every request, not a disabled
check, so a test asserts that every scope a view declares has a rate.
Schema¶
The OpenAPI document is generated by drf-spectacular, committed to
docs/api/openapi.yaml, and served at /api/v1/schema/ (Swagger UI at
/api/v1/docs/). CI regenerates it and fails on any diff, so the committed file
is the contract: a code change that alters the API cannot pass review without
the schema being regenerated beside it.
DJANGO_SETTINGS_MODULE=flowforge.settings.test \
uv run python manage.py spectacular --file docs/api/openapi.yaml
Generation is deterministic — verified by generating twice and diffing — which is what makes the drift check reliable rather than flaky.
Known limitations¶
- Nothing schedules a run. Triggering creates the task runs in their initial states and stops. Claiming and executing them is Phase 4 and Phase 5.
- Throttle counters are per-process unless the cache is shared. The default cache is process-local, so a multi-process deployment needs Redis before the rates above are exact cluster-wide.
- The schema is checked for drift, not for semantics. A view that drifts from the committed file is caught; a committed file that was always wrong is not.