Routes and DTOs

The HTTPRouter class maps incoming requests to handler callbacks. Each handler receives the path parameters, query parameters, headers, and body.

Handler signature

Every route handler uses this signature. Return an HTTPResponse or a Dictionary.

func _handler(path_params: Dictionary, query: Dictionary, headers: Dictionary, body: Variant) -> HTTPResponse:
    return HTTPResponse.ok("Handled")

Register routes

Use the convenience methods for each HTTP method:

server.router.handle_get("/users", _list_users)
server.router.handle_post("/users", _create_user)
server.router.handle_put("/users/{id}", _update_user)
server.router.handle_delete("/users/{id}", _delete_user)
server.router.handle_patch("/users/{id}", _patch_user)

Supported methods: GET, POST, PUT, DELETE, PATCH, HEAD, and OPTIONS. Trailing slashes are normalized, so /users and /users/ resolve to the same route.

Wildcard (catch-all) routes

Use a {param:.*} segment to match the rest of the path, including slashes:

server.router.handle_get("/assets/{path:.*}", _serve_asset)
# /assets/img/icons/a.png -> path_params["path"] == "img/icons/a.png"

Custom 404 handler

Set a fallback for unmatched routes. The callback receives (path, query_params, headers) and returns an HTTPResponse:

server.router.set_not_found_handler(func(path, _query, _headers):
    return HTTPResponse.not_found("No route for %s" % path)
)

Automatic OPTIONS and Allow

When a 405 is returned, the router adds an Allow header listing the methods that do match the path. An OPTIONS request to an existing route without an explicit OPTIONS handler returns 204 with the same Allow list.

Path parameters

Wrap a segment in braces to capture it. Access the value through path_params:

server.router.handle_get("/users/{id}", _get_user)

func _get_user(path_params: Dictionary, _query, _headers, _body) -> HTTPResponse:
    var user_id: String = path_params["id"]
    return HTTPResponse.ok("User: %s" % user_id)

Add a regex constraint after a colon to restrict the match:

# Matches only numeric IDs
server.router.handle_get("/users/{id:[0-9]+}", _get_user)

Route groups

Use group() to share a path prefix and scoped middleware. Nested groups append their prefixes:

server.router.group("/api/v1", func(r: HTTPRouter):
    r.handle_get("/users", _list_users)
)

The group above serves GET /api/v1/users.

Dictionary responses

A handler may return a Dictionary instead of an HTTPResponse:

return {"code": 200, "status": "OK", "body": "Hello", "headers": {"X-Custom": "value"}}

DTO validation

Pass a DTO class as the third argument to validate JSON bodies. The handler receives a resolved HTTPDto instance:

class_name CreateUserDto
extends HTTPDto

func _init() -> void:
    register_field("name", TYPE_STRING)
    register_field("age", TYPE_INT)
    register_optional_field("email", TYPE_STRING)
    register_validated_field("password", TYPE_STRING, func(p): return p.length() >= 8)
    register_nested_dto("address", AddressDto)
    register_array_field("tags", TYPE_STRING)

Register the route with the DTO class:

server.router.handle_post("/users", _create_user, CreateUserDto)

Invalid payloads return 400 Bad Request.

Available registration methods

MethodPurpose
register_fieldRequired field of a built-in type
register_optional_fieldField that may be missing
register_validated_fieldField with a custom validator callback
register_nested_dtoNested object validated by another DTO class
register_array_fieldArray of primitives or DTOs

Read DTO values

func _create_user(_path_params, _query, _headers, body: Variant) -> HTTPResponse:
    var dto: CreateUserDto = body
    var name: String = dto.get_value("name")
    var has_email := dto.has_value("email")
    return HTTPResponse.created("Created user: %s (%s)" % [name, has_email])

Use to_dict() to convert the DTO to a plain Dictionary.