Skip to main content

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)

Source distribution for expose-api 0.1.0
File Size Uploaded Provenance
expose_api-0.1.0.tar.gz 15.9 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for expose-api 0.1.0
File Interpreter ABI Platform
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 log

Release 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 log

Security 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.

Select from 2 files

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 log

A 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.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page