Getting started¶
This page takes you from nothing to a working model, and then to a REST API over it. Every file
shown here is in examples/ and is run
by the test suite.
1. What you need¶
- Python 3.11 or newer.
- MongoDB 7.0 or newer, running as a replica set. A single node is enough. Transactions,
cascading deletes and change streams do not work on a standalone
mongod; everything else does.
If you have Docker, this starts a one-node replica set on localhost:27017:
# MongoDB for the examples: a single-node replica set on localhost:27017.
# Transactions and change streams need a replica set; one node is enough.
# docker compose -f examples/compose.yaml up -d --wait
name: mongospecs-examples
services:
mongo:
image: mongo:8.0
command: ["mongod", "--replSet", "rs0", "--bind_ip_all"]
ports:
- "27017:27017"
volumes:
- data:/data/db
healthcheck:
test:
- CMD-SHELL
- >
mongosh --quiet --eval
"try { if (!db.hello().isWritablePrimary) throw 1 } catch (e) {
try { rs.initiate({_id: 'rs0', members: [{_id: 0, host: 'localhost:27017'}]}) } catch (e2) {}
quit(1) }"
interval: 2s
timeout: 10s
retries: 60
volumes:
data:
docker compose -f compose.yaml up -d --wait
MongoDB Atlas works too (every Atlas cluster is a replica set): use its connection string
wherever you see mongodb://localhost:27017/... below.
2. Install¶
Pick the model library you want to write your models in. If you have no preference, start with msgspec; Choosing a backend compares the three.
pip install "mongospecs[msgspec]" # or [pydantic], or [attrs]
3. A model, and what you can do with it¶
"""mongospecs in one file. Start MongoDB (see compose.yaml), then: python examples/quickstart.py"""
import asyncio
import os
from datetime import datetime
from typing import Annotated
from mongospecs import CreatedAt, Inc, Index, Q, Ref, Registry, Revision, SpecConfig, UpdatedAt
from mongospecs.msgspec import Spec # or mongospecs.pydantic / mongospecs.attrs
URI = os.environ.get("MONGO_URI", "mongodb://localhost:27017/quickstart?replicaSet=rs0")
class User(Spec):
name: str = ""
class Article(Spec):
__spec__ = SpecConfig(collection="articles", indexes=[Index("title", unique=True)])
title: str
views: int = 0
tags: list[str] = []
author: Ref[User] | None = None
created_at: Annotated[datetime | None, CreatedAt] = None
updated_at: Annotated[datetime | None, UpdatedAt] = None
rev: Annotated[int, Revision] = 0 # optimistic locking
async def main(uri: str = URI) -> None:
async with Registry(uri, models=[User, Article]) as registry:
await registry.apply_indexes()
ann = await User(name="ann").insert()
article = await Article(title="Hello", author=ann).insert()
# change tracking: save() sends only what changed
article.title = "Hello, world"
assert article.changes() == {"$set": {"title": "Hello, world"}}
await article.save()
# typed queries: names are checked, values are cast
popular = await Article.find(Q.views >= 0, Q.title.prefix("Hello")).sort("-views").limit(10)
assert [a.title for a in popular] == ["Hello, world"]
# relations: one extra query, or a $lookup
with_author = await Article.find(Q.author == ann).include("author").one()
print(with_author.title, "by", with_author.author.doc.name)
# atomic updates, always validated; the updated document comes back
article = await Article.update(article.id, Inc(views=1), tags=["intro"])
assert article.views == 1
# transactions (these need a replica set): the callback is retried on transient
# errors, and in-memory documents are restored if it aborts
async def publish(tx):
article.tags.append("published")
await article.save(session=tx)
await registry.transaction(publish)
print("tags:", (await Article.get(article.id)).tags)
if __name__ == "__main__":
asyncio.run(main())
$ python quickstart.py
Hello, world by ann
tags: ['intro', 'published']
What happened there:
- A model is a class.
Specis the base for the backend you chose; everything else comes frommongospecsand is the same on all three. Every document has anid, stored as_id. - A
Registryowns the connection. Models do nothing until they are in an open registry.async withopens it and closes it; in a web application you open it once at startup. - Nothing is built implicitly.
apply_indexes()creates the indexes you declared. save()sends only what changed, andchanges()shows you what that is.- Queries are typed.
Q.views >= 0checks thatviewsis a field and casts the value; a misspelt name raisesQueryErrorinstead of matching nothing. - A
Refis not loaded until you ask:.include("author")loads it with the query. Model.update(id, ...)is one atomic command and returns the updated document.- A transaction is a function. It may run more than once, and your in-memory documents are put back if it aborts.
Each of these has a page: Models, Queries, Writes and transactions, Relations.
Top-level await
The guide pages use await at the top level to stay short. In your own code that goes inside
an async def, as in the file above, or straight into python -m asyncio.
4. A REST API over the model¶
mongospecs-web describes an API as data and mounts it on plain ASGI, Starlette, FastAPI,
Litestar or Quart. This example uses plain ASGI, so the only other thing to install is a server.
pip install mongospecs-web uvicorn
"""A small REST API with mongospecs-web: per-user to-do lists.
pip install "mongospecs[msgspec]" mongospecs-web uvicorn
docker compose -f examples/compose.yaml up -d --wait
uvicorn app:app --app-dir examples/todo_api
curl -s -X POST localhost:8000/api/tasks -H 'authorization: Bearer ann-token' \
-H 'content-type: application/json' -d '{"title": "Write docs"}'
curl -s 'localhost:8000/api/tasks?done=false&sort=-title' -H 'authorization: Bearer ann-token'
"""
import os
from datetime import datetime
from typing import Annotated
from mongospecs import CreatedAt, Index, Policy, Q, Registry, Revision, SpecConfig
from mongospecs.msgspec import Spec
from mongospecs_web import Api, Create, Delete, Get, List, Patch, Resource, Unauthorized
URI = os.environ.get("MONGO_URI", "mongodb://localhost:27017/todo?replicaSet=rs0")
# Stands in for your real user store or token verification.
TOKENS = {"ann-token": "ann", "bob-token": "bob"}
class Task(Spec, kw_only=True):
__spec__ = SpecConfig(collection="tasks", indexes=[Index("owner", "title")])
# Policy says what a client may do with each field.
title: Annotated[str, Policy(filter=("eq", "prefix"), sort=True)]
done: Annotated[bool, Policy(filter=("eq",))] = False
owner: Annotated[str, Policy(insert=False, update=False)] = "" # set by the server
note_to_self: Annotated[str | None, Policy(private=True)] = None # never leaves the server
created_at: Annotated[datetime | None, CreatedAt] = None
rev: Annotated[int, Revision] = 0 # the ETag, checked by If-Match
def authenticate(request):
"""Who is calling: the principal, or None for an anonymous request."""
scheme, _, token = request.headers.get("authorization", "").partition(" ")
if not token:
return None
if scheme != "Bearer" or token not in TOKENS:
raise Unauthorized("Unknown token.", headers={"www-authenticate": "Bearer"})
return TOKENS[token]
def logged_in(ctx):
if ctx.principal is None:
raise Unauthorized("Log in first.", headers={"www-authenticate": "Bearer"})
return True
tasks = Resource(
Task,
ops=[List, Get, Create, Patch, Delete],
guards=[logged_in], # who may call the operations
scope=lambda ctx: Q.owner == ctx.principal, # which rows they may see and change
server_fields=lambda ctx: {"owner": ctx.principal}, # values the server sets on create
)
@tasks.op("complete", method="POST", summary="Mark a task as done")
async def complete(ctx):
task = await ctx.load() # 404 unless the task is within the caller's scope
task.done = True
return await task.save()
registry = Registry(URI, models=[Task])
api = Api(tasks, prefix="/api", title="To-do", authenticate=authenticate)
app = api.asgi(registry=registry) # opens the registry on startup and closes it on shutdown
$ uvicorn app:app
$ curl -s -X POST localhost:8000/api/tasks -H 'authorization: Bearer ann-token' \
-H 'content-type: application/json' -d '{"title": "Write docs"}'
{"data":{"id":"...","title":"Write docs","done":false,"owner":"ann","created_at":"...","rev":0}}
$ curl -s 'localhost:8000/api/tasks?done=false&sort=-title' -H 'authorization: Bearer ann-token'
$ curl -s localhost:8000/api/openapi.json
The parts:
Policyon each field is the contract. It says whether a client may set the field, change it, see it, filter by it and sort by it.ownercannot be written by clients;note_to_selfnever leaves the server.authenticateturns a request into a principal (anything you like), orNone.- Guards decide who may call an operation;
scopedecides which rows they may touch, and is part of every query, so Bob gets a 404 for Ann's task rather than a 403. @tasks.opadds an operation of your own next to the built-in ones.revis aRevisionfield, so responses carry anETagand writes honourIf-Match.
More in Resources and Authentication and authorization.
5. Test it¶
api.handle() runs a request through routing, authentication, guards, validation and the
database, without a server:
"""Testing the API without a server: api.handle() runs a request through the whole stack."""
import json
import pytest_asyncio
from app import URI, Task, api
from mongospecs import Registry
ANN = {"authorization": "Bearer ann-token", "content-type": "application/json"}
BOB = {"authorization": "Bearer bob-token", "content-type": "application/json"}
@pytest_asyncio.fixture(autouse=True)
async def database():
# A registry cannot be reopened once closed, so each test opens its own.
async with Registry(URI, models=[Task]) as registry:
yield
await registry.client.drop_database(registry.database().name)
async def call(method, path, body=None, headers=ANN):
response = await api.handle(
method, path, body=json.dumps(body).encode() if body is not None else b"", headers=headers
)
return response.status, json.loads(response.body) if response.body else None, response.headers
async def test_create_list_and_complete():
status, created, headers = await call("POST", "/api/tasks", {"title": "Write docs"})
assert status == 201 and created["data"]["owner"] == "ann" and headers["etag"] == '"0"'
assert "note_to_self" not in created["data"] # private fields are never returned
status, listed, _ = await call("GET", "/api/tasks")
assert [task["title"] for task in listed["data"]] == ["Write docs"]
status, done, _ = await call("POST", f"{headers['location']}/complete")
assert status == 200 and done["data"]["done"] is True
async def test_each_user_sees_only_their_own_tasks():
_, _, headers = await call("POST", "/api/tasks", {"title": "Ann's"})
assert (await call("GET", headers["location"], headers=BOB))[0] == 404
assert (await call("GET", "/api/tasks", headers=BOB))[1]["data"] == []
assert (await call("GET", "/api/tasks", headers={}))[0] == 401
async def test_the_field_policy_is_enforced():
_, _, headers = await call("POST", "/api/tasks", {"title": "Mine"})
status, problem, _ = await call("PATCH", headers["location"], {"owner": "bob"})
assert status == 422 and problem["errors"] == [{"field": "owner", "message": "cannot be set on update"}]
assert (await call("PATCH", headers["location"], {"note_to_self": "x"}))[0] == 422
async def test_a_stale_etag_is_refused():
_, _, headers = await call("POST", "/api/tasks", {"title": "Mine"})
fresh = {**ANN, "if-match": headers["etag"]}
assert (await call("PATCH", headers["location"], {"done": True}, fresh))[0] == 200
assert (await call("PATCH", headers["location"], {"done": False}, fresh))[0] == 412
Testing your API covers the other ways, including an in-memory backend.
Where next¶
- Coming from Mongoose? The parity table maps what you know.
- Coming from mongospecs 0.1? Migrating from 0.1.x.
- Going to production? Running in production and Tuning for production.
- Something raised an error you do not recognise? Errors.