expose-api
Turn ordinary typed dataclasses into JSON applications. Native ASGI, generated CRUD, and a standard-library development runner, with no runtime dependencies. Python 3.14+ is required.
from dataclasses import dataclass, field
from uuid import UUID, uuid4
from expose_api import App, MemoryStore
@dataclass
class Note:
title: str
content: str = ""
id: UUID = field(default_factory=uuid4)
app = App(title="Notes API")
notes = app.include(Note, store=MemoryStore(), public=True)
if __name__ == "__main__":
app.run()
Install from PyPI:
python -m pip install expose-api==0.1.0
From a checkout, uv sync --locked installs the package, and
uv run python examples/notes.py starts it at http://127.0.0.1:8000.
curl -i http://127.0.0.1:8000/notes \
-H 'Content-Type: application/json' \
-d '{"title":"First note"}'
curl http://127.0.0.1:8000/notes
The default path is the lowercase snake-case model name plus s. Supply
path="/people" for irregular names. Include each model with its own store before
the first ASGI call. Models and overlapping paths cannot be registered twice.
| Request | Result |
|---|---|
| GET /notes?offset=0&limit=100 | 200, JSON array |
| GET /notes/{id} | 200, record |
| POST /notes | 201, record and relative Location |
| PATCH /notes/{id} | 200, updated record |
| DELETE /notes/{id} | 204, empty body |
HEAD follows GET access checks and headers without a body. OPTIONS reports enabled
methods in Allow. Select routes with operations=("list", "get"); other CRUD
operations stay unavailable. Trailing slashes do not redirect. PUT is unsupported.
List results preserve insertion order, default to 100 records and allow at most
1,000. Pagination is not a snapshot across concurrent requests.
Models and validation
Fields support strings, booleans, integers, finite floats, UUIDs, string/integer
enums, Literal, unions and nullable types, typed lists, string-keyed dictionaries,
and nonrecursive nested dataclasses. Defaults and factories behave as in ordinary
Python. Frozen and slots dataclasses work. Properties and methods are never
exposed. Unsupported annotations, unresolved references, InitVars, init=False
fields and incompatible constructor signatures fail at registration.
Validation is strict: booleans are not integers, strings are not numbers, unknown
fields fail, and null requires a nullable type. Integer JSON values may populate
float fields. The default identity field is id; select another with id_field=.
It must be a readable string, integer or UUID and cannot change through PATCH.
Strings must be nonempty single URL segments of at most 256 characters, without
control characters, slashes, backslashes, . or ... Required identities are
supplied at creation; default factories can generate them. Integer allocation is
not inferred. PATCH changes only supplied fields and replaces a nested field in
full, rather than recursively merging it.
Set visibility with ordinary dataclass metadata:
created_by: str = field(default="system", metadata={"expose_api": {"read_only": True}})
password: str = field(default="", metadata={"expose_api": {"write_only": True}})
Read-only fields require defaults and reject input. Write-only fields accept input
and never appear in serialized output. Visibility applies to nested models too.
Defaults, constructor results and output values are validated; constructors and
__post_init__ must remain fast and free of external side effects.
POST and PATCH require UTF-8 application/json or application/*+json. The app
counts actual streamed bytes against max_body_size (default 1 MiB), checks
Content-Length when supplied, rejects duplicate JSON keys and non-finite numbers,
and limits nesting to 32. Only offset/limit query parameters on list are supported.
Errors use a stable JSON shape:
{"error":{"code":"validation","message":"Data does not match the model.","details":[{"path":["title"],"code":"type"}]}}
Validation errors return 422; malformed JSON/query/identity returns 400, denied access 403, missing records/routes 404, unsupported methods 405, conflicts 409, oversized bodies 413, unsupported media 415, and unexpected errors 500. Details contain field paths and codes, without submitted values or dynamic dictionary keys. Unexpected failures log their exception type without exception text.
Access and direct Python use
Including a model requires either public=True or an authorization callback:
def can_read(request):
return request.operation in {"list", "get"}
app.include(Note, store=MemoryStore(), authorize=can_read)
The callback receives Request(scope, model, operation, identity), may be async,
and must return a boolean. False denies before body parsing or store access.
request.header(name) returns a single header value, or None when absent or
repeated. OPTIONS exposes route metadata without invoking the policy.
Policies are resource-level access hooks. They do not authenticate credentials, filter individual rows or provide tenant isolation. Those require an appropriate application policy and store/service. Public exposure must be selected explicitly.
The returned Resource also supports direct Python calls:
note = await notes.create({"title": "A note"})
note = await notes.update(note.id, {"content": "Changed"})
page = await notes.list(offset=0, limit=20)
await notes.delete(note.id)
Direct calls bypass HTTP authorization; the Python caller owns that trust boundary.
ModelSchema supplies independent create/patch/dump validation. Store[T] defines
async create/get/list/update/delete methods. An update applies a synchronous
transform inside the backend's atomic operation; if it fails, the stored record
must remain unchanged. MemoryStore copies values on input/output and keeps
mutations atomic, including validation before commit. It has no persistence,
cross-process state sharing or capacity eviction.
ASGI and lifecycle
app is an ASGI 3 callable and can be used by independently installed ASGI
servers. HTTP and lifespan are implemented directly. Unsupported scope types,
including WebSocket, raise ProtocolError. Standard ASGI middleware can wrap the
application. Mounted root paths are reflected in relative Location headers;
generated links do not trust the Host header.
from contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan(app):
app.state["ready"] = True
try:
yield
finally:
app.state.clear()
app = App(lifespan=lifespan)
app.run(host="127.0.0.1", port=8000) blocks until interrupted. Inside an existing
event loop, use async with serve(app, port=8000) as server: and await
server.serve_forever(). Import serve from expose_api. Lifespan runs once and
pending connections close during shutdown.
The built-in runner is for development: HTTP/1.0/1.1, origin-form targets,
fixed-length request bodies, one request per connection, 32 KiB/100-header bounds,
10-second request timeout and at most 100 connections by default. serve() exposes
timeout/connection options. It rejects chunked requests, ambiguous lengths,
Expect, unsupported versions and invalid header syntax. It supplies no TLS,
HTTP/2, WebSocket upgrades, keep-alive or production server guarantees.
Development and release preparation
uv sync --locked
uv run --frozen python -W error::ResourceWarning -m unittest discover -s tests -v
uv run --frozen python scripts/check_distribution.py
The distribution check verifies explicit archive allowlists, metadata, zero runtime requirements, source/wheel parity and clean installations of both artifacts, then runs the suite and example outside the checkout. CI runs on Linux, macOS and Windows. Release preparation describes the separate publishing workflow.
The Project and issues track later SQLite, OpenAPI, WebSocket and optional Hidden Moves/Backpack/CLI integrations. Version 0.1.0 does not include those features. License terms have not yet been specified.
Metadata
Release files for expose-api 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| expose_api-0.1.0.tar.gz | 15.9 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| expose_api-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 35.3 kB
Release files / expose_api-0.1.0.tar.gz
| Download URL | expose_api-0.1.0.tar.gz |
|---|---|
| Size | 15.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
09524398b674d0f68b0b403c2f1dc17c114c88d0361cb231c5e27d914a87f0a7
|
|
BLAKE2b-256 checksum How to use checksums |
1d66e82b4e8d0de0fe6b2d0f84e64dd692200631ea2bb0a68e8f84b28b428500
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 19, 2026.
Transparency logRelease files / expose_api-0.1.0-py3-none-any.whl
| Download URL | expose_api-0.1.0-py3-none-any.whl |
|---|---|
| Size | 19.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
58306cce00fe003a6c7d76c0757389b6fa60d7b9ff7f5781fcdca935b4b84fbb
|
|
BLAKE2b-256 checksum How to use checksums |
259ad3c330f6d5e22db5ac5cf4efde7244e57eacf4a249630a55f11491d09725
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 19, 2026.
Transparency logSecurity for expose-api 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for expose_api-0.1.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 19, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.