Routing
OxAPY routes map a URL path and an HTTP method to a Python handler. Routes are created with decorators such as @get and @post, then registered with a Router, which is attached to the server.
Route decorators
Every HTTP method has a matching decorator: @get, @post, @put, @patch, @delete, @head, and @options.
from oxapy import Oxapy, Router, get, post, put, delete
@get("/items")
def list_items(request):
return {"items": []}
@post("/items")
def create_item(request):
return {"status": "created"}
@put("/items/{item_id:int}")
def update_item(request, item_id):
return {"status": "updated", "item_id": item_id}
@delete("/items/{item_id:int}")
def delete_item(request, item_id):
return {"status": "deleted", "item_id": item_id}
def main():
(
Oxapy(("127.0.0.1", 5555))
.attach(
Router().routes([list_items, create_item, update_item, delete_item])
)
.run()
)
if __name__ == "__main__":
main()
The decorators are also plain functions, so you can pass a handler directly:
from oxapy import Router, get
def hello_handler(request):
return "Hello World!"
route = get("/hello", hello_handler)
router = Router().route(route)
Registering routes
Use .route() for a single route and .routes() for a list. Both return the router, so calls can be chained.
router = (
Router()
.route(get("/health", lambda _: "OK"))
.routes([list_items, create_item])
)
Path parameters
Path parameters are declared with curly braces and passed to the handler as keyword arguments. By default, all parameters are strings.
from oxapy import Router, get
@get("/hello/{name}")
def hello(request, name):
return f"Hello, {name}!"
@get("/users/{user_id:int}")
def get_user(request, user_id):
return {"user_id": user_id, "name": f"User {user_id}"}
Typed parameters
Three types are built in: {name:str}, {name:int}, and {name:slug}. Using {user_id:int} gives you an int in the handler without manual conversion.
@get("/users/{user_id:int}")
def get_user(request, user_id: int):
return {"user_id": user_id}
{name:slug} normalizes the captured value into a URL-friendly slug: it is lowercased, non-ASCII characters are stripped (accents are decomposed first, so é becomes e), runs of non-alphanumeric characters become single hyphens, and leading/trailing hyphens are removed.
@get("/blog/{post_slug:slug}")
def get_post(request, post_slug: str):
return {"slug": post_slug}
A request to /blog/Hello-World calls the handler with post_slug="hello-world".
Catch-all parameters
Use {*path} to match one or more path segments. This is handy for static files and downloads.
@get("/files/{*path}")
def serve_file(request, path):
return f"Requested file: {path}"
A request to /files/docs/readme.txt calls the handler with path="docs/readme.txt".
Handler signature checking
OxAPY verifies at decoration time that your handler can accept every path parameter declared in the route. It inspects the handler's signature and raises ValueError immediately if a parameter is missing, so a typo fails when the app starts rather than when a request arrives:
@get("/users/{user_id:int}")
def get_user(request):
return {"name": "nobody"}
ValueError: Missing required route argument 'user_id'
The fix is to accept the argument:
@get("/users/{user_id:int}")
def get_user(request, user_id):
return {"user_id": user_id}
The check runs whenever a handler is bound to a path, which covers both calling styles:
@get("/users/{user_id}") # decorator
def get_user(request, user_id): ...
route = get("/users/{user_id}", get_user) # decorator used as a function
route = Route("/users/{user_id}")(get_user) # Route called with the handler
Details worth knowing:
- It matches parameter names literally. The name inside the braces must appear in the handler signature.
{user_id}requires auser_idparameter;userIdwill not do. - The type annotation is stripped before checking.
{user_id:int}is checked asuser_id, notuser_id:int. - A leading
*is stripped too, so{*path}requires apathparameter. **kwargsdoes not satisfy the check. The comparison is against literal parameter names, sodef handler(request, **kwargs)still raises — declare the parameters explicitly.- No check runs for a handler-less route.
Route("/users/{user_id}")constructed without a handler is not validated, because there is no signature to inspect yet.
Router base path
A Router can be created with a base_path that is prepended to every route registered on it. This is the recommended way to version an API.
from oxapy import Oxapy, Router, get
@get("/users")
def get_users(request):
return [{"id": 1, "name": "user1"}]
def main():
(
Oxapy(("127.0.0.1", 5555))
.attach(Router("/api/v1").route(get_users))
.run()
)
if __name__ == "__main__":
main()
The endpoint is now served at http://127.0.0.1:5555/api/v1/users.
Multiple routers
You can attach any number of routers to a server. They are checked in order until a matching route is found.
public_api = Router("/api").route(get("/health", lambda _: "OK"))
admin_api = Router("/admin").middleware(auth_middleware).route(get("/stats", stats))
Oxapy(("127.0.0.1", 5555)).attach(public_api).attach(admin_api).run()
Using separate routers is also how you isolate middleware to specific groups. See the Middleware guide.
Unmatched routes
When no route matches the request, the server responds with 404 Not Found. Note that a request whose method does not match any route on the path is also answered with 404 (rather than 405) in the current implementation.
Next steps
- Requests — read headers, query strings, JSON bodies, forms, and uploads
- Responses — return values, status codes, and custom headers
- Middleware — process requests before handlers run
- Static Files — serve a directory with
static_file()