REST API¶
All endpoints are CORS-open. Editing endpoints can be disabled with
create_app(..., allow_edit=False). A server given a token (--token, CHUNKMIRAGE_TOKEN,
create_app(..., token=...)) answers /api/* only with it, as Authorization: Bearer
<token> or ?token=<token> (for /api/events: a browser's EventSource sends no
headers), and 401 otherwise. The datasets themselves and / stay open, since viewers send
no headers.
| method | path | purpose |
|---|---|---|
GET |
/ |
index: datasets, their specs, source URLs per format, viewer_url of an attached python viewer (or null), cache stats |
GET |
/api/ops |
registered ops with halo, cache flag, docstring and JSON schema |
GET |
/api/datasets |
dataset names |
POST |
/api/datasets |
create: body {"name": ..., "spec": PipelineSpec} (or the spec with a name field) |
GET |
/api/datasets/{name} |
spec, digest, per-level shape/chunks/dtype/voxel size, source URLs |
PUT |
/api/datasets/{name} |
replace the pipeline live; body is a PipelineSpec; returns new digest and URLs |
DELETE |
/api/datasets/{name} |
remove |
POST |
/api/datasets/{name}/refresh |
rebuild the pipeline so its ops' cache_token is read again (weights or files they depend on changed); {"changed", "digest", "sources"}, and a change event if the digest moved (caching) |
GET |
/api/datasets/{name}/neuroglancer |
?format=n5|zarr|zarr3|precomputed&viewer=... → {"source", "url"} |
GET |
/api/neuroglancer |
same query; one viewer state with a layer per dataset → {"state", "url", "sources"} |
GET |
/api/events |
Server-Sent Events; change event on start and after every edit, with digests and source URLs |
GET |
/ui |
built-in control page; see Interactivity |
GET |
/api/cache |
cache stats |
DELETE |
/api/cache |
clear cache |
GET |
/api/queue |
work waiting and running: chunk requests (requests) and each queue of expensive work (refined blocks, and op <name> for each op with slots), per level, with what was dropped because its clients left (caching) |
GET |
/{name}/{format}/{path} |
the spoofed dataset; see Formats |
GET |
/{name}/@{digest}/{format}/{path} |
same, with a cache-busting token |
PipelineSpec¶
{
"source": "/path/or/url", // zarr/n5/precomputed array or multiscale group, or file.h5::/dataset
"ops": [{"op": "threshold", "low": 120}],
"chunk_shape": [64, 64, 64], // optional; default: source chunk shape
"cache_source": true, // cache raw chunks (stage 0)
"voxel_size": [8, 8, 8], // optional overrides of what the source reports
"units": ["nm", "nm", "nm"],
"axes": ["z", "y", "x"],
"translation": [0, 0, 0],
"kind": "image", // optional: image, label or mask, over what the source guesses
"padding": "edge", // what ops see past the volume's edge: edge (repeated) or zero
"input_level": "resample", // an op at one voxel size no level has: resample a finer level, or read the nearest
"level_rtol": 0.01 // how near a level's voxel size must be to count as the one asked for
}
Responses to POST, PUT and GET /api/datasets/{name} include digest, source_dtype,
per-level levels (shape, chunks, dtype, voxel size, units, axes, and kind: image, label, mask or null), ops_info (per op: name, per-axis halo, docstring), reads (for an op at one voxel size, what it reads: level, voxel_size, resampled; else null), and sources, a map
from format to Neuroglancer source URL carrying the new digest.
Datasets resolved by name¶
A dataset can also exist before anyone registers it. Give the registry a resolver, a
function from a name to a PipelineSpec (or a dict of one, or a Pipeline), or None for
a name it does not know: DatasetRegistry(resolver=...), create_app(..., resolver=...),
or chunkmirage serve ... --resolver module:function. The first request for an
unregistered name, whether a viewer's chunk or metadata or GET /api/datasets/{name}, asks
the resolver. What it returns is built, registered under that name (so /api/events reports
it), and served from then on like any other dataset. Concurrent requests for a new name
build it once. A resolver raising ValueError answers 400 with its message, anything else
500.
This makes links that carry their pipeline in the name, decoded by the resolver, work on a fresh server with no setup step:
def resolve(name):
if not name.startswith("thr-"):
return None
return {"source": "s3://bucket/em.zarr", "ops": [{"op": "threshold", "low": int(name[4:])}]}
The token does not guard dataset paths (viewers send no headers), so a resolver is reachable by anyone who can reach the server. Build only what you would serve to them, and nothing that names an arbitrary file or URL from the request.
Routes of your own¶
A package can add endpoints next to these: a plugin's controls, say. Pass Starlette routes
to create_app(..., extra_routes=[...]), or declare a chunkmirage.routes entry point
naming a function that takes the app's DatasetRegistry and returns routes:
[project.entry-points."chunkmirage.routes"]
myplugin = "myplugin.server:routes"
from starlette.responses import JSONResponse
from starlette.routing import Route
def routes(registry):
async def names(request):
return JSONResponse(registry.names())
return [Route("/api/myplugin/names", names)]
Installed plugins' routes are added to every app, chunkmirage serve's included
(create_app(..., route_plugins=False) leaves them out). One that fails to load is skipped
with a warning. They are matched after the built-in routes and before the datasets, so a
route of your own wins over a dataset of the same name. Put them under /api/ to have them
guarded by the token too.
Mounted in another app¶
The app works as a sub-app, Mount("/prefix", create_app(...)) in your own Starlette or
FastAPI app: every path above is then under /prefix, the token guards /prefix/api/*,
links carry the prefix, and the control page calls the API relative to where it is served.
A mounted app gets no lifespan events, so the threadpool is sized by the first chunk
request instead (it is the host's pool too, and only ever made larger).
Serving it from Python¶
chunkmirage.serve(app, host="0.0.0.0", port=0, *, on_ready=None, ssl=None) serves an app
as chunkmirage serve does, for an application that builds its own (create_app, or its
own app with chunkmirage mounted in it). The port is bound before anything announces it
(port=0: any free one). on_ready(server) is called once connections are accepted. It
returns a running Server with port, url and stop(), served from a background thread;
block=True serves in the calling thread instead, until stopped.
import chunkmirage
server = chunkmirage.serve(app, port=0, on_ready=lambda s: print("ready at", s.url))
...
server.stop()
Server(app, sock, ...) takes a socket bound already (netutil.bind_socket), when the
address must be known before the app is built.