Skip to main content

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 a user_id parameter; userId will not do.
  • The type annotation is stripped before checking. {user_id:int} is checked as user_id, not user_id:int.
  • A leading * is stripped too, so {*path} requires a path parameter.
  • **kwargs does not satisfy the check. The comparison is against literal parameter names, so def 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()