Quickstart¶
Build a running API in about a minute. No config files and no route registration — you create folders and files, EndoCore serves them. This page walks through exactly what each step produces and why, including the couple of things that trip people up the first time (they're called out explicitly below, not left for you to discover).
1. Scaffold a project¶
pip install "endocore[watch]" # [watch] enables the dev-server auto-reload — see step 2
endo new blog
cd blog
PowerShell
In PowerShell type endo instead of endo (endo new blog) — endo is a
reserved word there (it's a PowerShell block keyword). Same CLI, different
name; every other command below works identically with either.
endo new blog writes exactly this, nothing more (this is endocore/cli/commands/new.py,
not a guess):
blog/
Api/
v1/
Health/
Get.py # GET /v1/health — a liveness probe, so `endo dev` has
# something to serve immediately
Middleware/
__init__.py # an empty `middlewares = [...]` list, commented examples
Services/ Models/ Utils/ Tests/ # empty except a .gitkeep each
hooks.py # `on_startup` / `on_shutdown` lists, both empty stubs
README.md
There is no extensions.py yet — that file is optional and only matters
once you actually wire up a service integration (Redis, Celery, email); see
Extensions. Don't create it speculatively.
2. Run the dev server¶
You should see something like:
[INFO] EndoCore booted: loaded 1 route, 0 middleware, 0 files with errors
[INFO] dev watcher on: .../blog/Api
[INFO] Uvicorn running on http://127.0.0.1:8000
Visit http://127.0.0.1:8000/v1/health — you'll get {"status": "ok"}.
Open http://127.0.0.1:8000/docs for the interactive Swagger UI, where you
can try every endpoint from the browser. (/docs is on automatically here
because endo dev runs with dev=True — in a production deployment it's off
by default; see Configuration.)
No dev watcher on: ... line? Reload won't happen
The in-process file watcher needs the optional watchfiles package —
that's what pip install "endocore[watch]" in step 1 was for. Without it,
endo dev still runs the app perfectly normally, it just logs dev watcher
disabled (install 'watchfiles' for auto-reload) instead, and every save
in steps 3–6 below will need a manual restart (Ctrl+C, endo dev again)
to take effect. If you skipped the [watch] extra, install it now:
pip install watchfiles.
3. Add an endpoint¶
Either scaffold it:
endo create PascalCases each path segment for you (user → User) and
writes a minimal handler body; it targets the latest existing version
when you don't specify one (here, v1, since it's the only one). Run it
again with the same arguments and it won't clobber your edits — it prints
exists: ... and leaves the file alone.
…or just create the file yourself — endo create is a convenience, not a
requirement. The folder is the path, the file name is the method, full
stop:
# Api/v1/User/Profile/Get.py
from endocore import Request, Response
async def handler(request: Request) -> Response:
return Response.json({"name": "Ada", "role": "admin"})
Save the file — with the watcher running, EndoCore rescans Api/ and
re-imports the changed handler in-process (no server restart, no dropped
connections). Open http://127.0.0.1:8000/v1/user/profile and the endpoint
is live. See Routing for the full rule set, including
several edge cases worth knowing before your tree grows past a handful of
files.
4. Dynamic segments¶
A folder named [id] captures a path parameter, verbatim (not lower-cased,
unlike ordinary folder names — see Routing):
# Api/v1/User/[id]/Get.py
from endocore import Request, Response
async def handler(request: Request) -> Response:
user_id = request.path_params["id"] # "42" for /v1/user/42 — always a str
return Response.json({"id": user_id})
request.path_params["id"] is always a str — EndoCore never guesses that
"42" should become the int 42. Cast it yourself
(int(request.path_params["id"])) if your handler needs a number; an
invalid cast is your code's problem to handle (e.g. raise
HTTPError(422, "id must be numeric") around it), not the router's.
5. Read input¶
# Api/v1/User/Post.py (POST /v1/user)
from endocore import Request, Response, HTTPError
async def handler(request: Request) -> Response:
data = await request.json()
if not data.get("name"):
raise HTTPError(422, "name is required")
return Response.json({"created": data["name"]}, status=201)
await request.json() returns None for an empty body (not an error) and
raises BadRequest (→ 400) for a body that isn't valid JSON — so
data.get("name") above would raise AttributeError on None if the
client sent no body at all; guard for that explicitly
(data = await request.json() or {}) unless an empty body should genuinely
be a 500. request also gives you request.query (parsed query string),
request.headers, request.cookies, and await request.form() /
await request.files() for multipart uploads — see
Request & Response.
6. Add the ORM¶
# Models/blog.py
from endocore.orm import Model, fields, configure, create_all
configure(backend="sqlite", database="blog.db")
class Post(Model):
title = fields.CharField(max_length=200)
body = fields.TextField(default="")
create_all(Post)
# Api/v1/Post/Post.py (POST /v1/post)
from endocore import Request, Response
from Models.blog import Post
async def handler(request: Request) -> Response:
data = await request.json()
post = Post.objects.create(title=data["title"], body=data.get("body", ""))
return Response.json({"id": post.pk, "title": post.title}, status=201)
Two things worth understanding here, not just copying:
configure()andcreate_all()run once, the first timeModels.blogis imported — Python caches modules insys.modules, so no matter how many different handler files dofrom Models.blog import Post, the module body (and thereforeconfigure/create_all) executes exactly once, at whichever moment the first import happens (in practice: during boot, since EndoCore eagerly imports every discovered handler). This is why the example works with zero explicit "app startup" wiring.- Call
configure()for the"default"connection in exactly one place — if two different model files both callconfigure(backend="sqlite", database=...)for the same alias, whichever one Python happens to import second silently replaces the first connection (configure()always overwrites; see Transactions). Keep one file (this one,Models/blog.py, or a dedicatedModels/db.pyin a bigger app) as the single place that callsconfigure(), and have every other model file justimport fields, Modeland rely on that one having already run.
blog.db is created relative to the process's current working
directory (wherever you ran endo dev from), not relative to the model
file — worth knowing before you wonder why the file shows up somewhere
unexpected. See the full ORM guide for fields, lookups,
relations, and the async API.
7. Inspect your app¶
endo routes # list every route + the file it maps to
endo check # find broken handlers, duplicate routes, skipped files (with reasons)
endo openapi # print the OpenAPI schema
endo check is worth running any time something should be routed but
isn't showing up — it prints exactly why the scanner skipped each such file
(wrong version folder, wrong location), rather than leaving you to guess.
That's the whole loop. Next, follow the Tutorial to build a complete, versioned API with services, middleware and migrations.