Skip to content

feat: Phase 2 — route table, one handler serving multiple routes - #494

Closed
mathieucarbou wants to merge 2 commits into
mainfrom
feat/routing_table
Closed

mathieucarbou wants to merge 2 commits into
mainfrom
feat/routing_table

Conversation

@mathieucarbou

Copy link
Copy Markdown
Member

Summary

This PR implements Phase 2 — a route table refactoring that decouples URI/method matching from handler objects. One handler object can now serve multiple routes, combining Phase 1's ownership control with Phase 2's routing flexibility.

Builds on #493 (Phase 1: non-owning handler registration). Fully backward compatible.

Motivation

In the #468 discussion, @inF1704 pointed out that URI matching lives inside handler objects (AsyncCallbackWebHandler carries _uri and _method), which prevents one handler from serving multiple routes. This PR moves URI/method matching into the server's routing table.

API Changes

Route Registration

Call Ownership
server.addRoute(uri, method, new MyHandler()) Server owns (default)
server.addRoute(uri, method, ptr, false) Non-owning (pointer)
server.addRoute(uri, method, myHandler) Non-owning (reference)

Route Removal

Call Effect
server.removeRoute(uri, method) Remove a specific route
server.removeHandler(&handler) Removes ALL routes + legacy handler entries for that handler

Usage

One Handler, Multiple Routes

static AsyncWebServer server(80);

// One handler object — lives in static RAM (Phase 1)
static AsyncCallbackWebHandler greetHandler;

void setup() {
  greetHandler.onRequest([](AsyncWebServerRequest *request) {
    request->send(200, "text/plain", "Hello from " + request->url());
  });

  // Same handler on three different routes (Phase 2):
  server.addRoute(AsyncURIMatcher::exact("/hello"), HTTP_GET, greetHandler);
  server.addRoute(AsyncURIMatcher::exact("/hi"), HTTP_GET, greetHandler);
  server.addRoute(AsyncURIMatcher::prefix("/greet/"), HTTP_GET, greetHandler);

  server.begin();
}

PSRAM Handler on Multiple Routes (ESP32)

#include <esp_heap_caps.h>

void *mem = heap_caps_malloc(sizeof(AsyncCallbackWebHandler), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
auto *handler = new (mem) AsyncCallbackWebHandler();
handler->onRequest([](AsyncWebServerRequest *request) {
  request->send(200, "application/json", "{\"ok\":true}");
});

// Same PSRAM handler on two routes — non-owning:
server.addRoute(AsyncURIMatcher::prefix("/api/"), HTTP_GET, handler, false);
server.addRoute(AsyncURIMatcher::exact("/status"), HTTP_GET, handler, false);

Method-Based Routing

The route table filters by HTTP method — a GET route won't match a POST request:

server.addRoute(AsyncURIMatcher::exact("/data"), HTTP_GET, handler);
// POST /data falls through to the next handler or catch-all

Design

Request received
  → Rewrites applied
  → Route table checked first (Phase 2):
      for each route: method matches? URI matches? filter passes? → attach handler
  → Legacy handlers checked (Phase 1, backward compat):
      for each handler: filter passes? canHandle() returns true? → attach handler
  → Catch-all handler (or bare 501 if none)

Internal Changes

Component Change
RouteEntry struct { AsyncURIMatcher uri, WebRequestMethodComposite method, bool hasMatcher, HandlerEntry entry }
_routes std::list<RouteEntry> — evaluated before _handlers
_attachHandler() Routes first (URI+method match), then legacy handlers (canHandle)
AsyncCallbackWebHandler::_routeManaged When true, canHandle() skips URI/method checks
AsyncURIMatcher::operator== Value comparison for removeRoute()
removeHandler() Also removes routes pointing to that handler
reset() Clears both _routes and _handlers

Backward Compatibility

✅ 100% additive — all existing code works identically:

  • server.on() / serveStatic() / addHandler() — unchanged
  • Handlers registered via addHandler() use canHandle() dispatch (legacy path)
  • Handlers registered via addRoute() use route table dispatch (new path)
  • Both paths coexist; routes take priority
  • Existing examples compile without modification

Files Changed

File Change
src/ESPAsyncWebServer.h RouteEntry struct, addRoute/removeRoute declarations, AsyncURIMatcher::operator==
src/WebServer.cpp Route table dispatch in _attachHandler, addRoute/removeRoute impl, removeHandler extended
src/WebHandlerImpl.h AsyncCallbackWebHandler::_routeManaged flag, _setRouteManaged()
src/WebHandlers.cpp canHandle() checks _routeManaged to skip URI/method matching
examples/arduino/MultiRoute/MultiRoute.ino New example: one handler on 3 routes, PSRAM on 2 routes, classic on()
docs/routing.md Route Table section with examples
docs/configuration.md Route table section
docs/concepts.md Route table note
platformio.ini MultiRoute example entry

Related

Checklist

  • Fully backward compatible (Phase 1 + existing APIs)
  • Compiles on host emulator (Arduino-Emulator + CMake)
  • New example follows existing conventions (AP mode, ASYNCWEBSERVER_WIFI_SUPPORTED guards)
  • Documentation updated (routing.md, configuration.md, concepts.md)
  • Routes evaluated before legacy handlers
  • removeHandler() works for both route-registered and legacy handlers

mathieucarbou and others added 2 commits October 12, 2026 12:15
Give users full control over where handler objects live in memory
(internal SRAM, PSRAM, static RAM) without allocator hooks or
operator new overrides.

Changes:
- HandlerEntry struct tracks ownership explicitly (unique_ptr + raw ptr)
- addHandler(ptr, takeOwnership=true) — extended with ownership flag
- addHandler(ref) — non-owning reference overload for stack/static/PSRAM
- AsyncWebServer(port, catchAllPtr, takeOwnership=true) — custom catch-all at construction
- AsyncWebServer(port, catchAllRef) — non-owning reference overload
- setCatchAllHandler(ptr, takeOwnership=true) / setCatchAllHandler(ref)
- nullptr support: AsyncWebServer(port, nullptr) disables catch-all entirely;
  unmatched requests get a bare 501 response
- catchAllHandlerPtr() — safe accessor returning nullptr instead of crashing
- catchAllHandler() deprecated in favor of catchAllHandlerPtr()
- onNotFound/onFileUpload/onRequestBody/reset are no-ops when no catch-all
- New CustomAllocation example demonstrating static RAM, PSRAM, and
  server-owned handler patterns
- Updated docs: routing.md, configuration.md, concepts.md

Fully backward compatible: all existing APIs work unchanged.
Existing sketches require zero modifications.

Motivation: ESP32 internal SRAM is scarce (lwIP, WiFi buffers).
Long-lived handler objects can now be moved to PSRAM or static RAM,
freeing internal SRAM for networking. See discussion in #468.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Decouple URI/method matching from handler objects by introducing a
server-side routing table (RouteEntry). This enables one handler object
to serve multiple routes, combining Phase 1's ownership control with
Phase 2's routing flexibility.

Changes:
- RouteEntry struct: { AsyncURIMatcher uri, WebRequestMethodComposite method,
  bool hasMatcher, HandlerEntry entry }
- addRoute(uri, method, handlerPtr, takeOwnership=true) — owning registration
- addRoute(uri, method, handlerRef) — non-owning registration
- removeRoute(uri, method) — remove a specific route
- removeHandler() now also removes routes pointing to that handler
- _attachHandler() evaluates routes first (URI+method match), then legacy
  handlers (canHandle-based dispatch) — backward compatible
- AsyncCallbackWebHandler::_routeManaged flag — when set, canHandle()
  skips URI/method checks (server's route table does them)
- AsyncURIMatcher::operator==/!= for route removal by value comparison
- reset() clears both _routes and _handlers
- New MultiRoute example: one handler on 3 routes (static RAM),
  PSRAM handler on 2 routes, classic on() handler, custom catch-all
- Updated docs: routing.md (Route Table section), configuration.md,
  concepts.md

Backward compatible: all Phase 1 APIs and existing on()/addHandler()/
serveStatic() calls work identically. Routes are additive.

Builds on #493 (Phase 1: non-owning handler registration).
Motivation from #468 discussion.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@mathieucarbou

Copy link
Copy Markdown
Member Author

Closing in favor of #495 — this PR was created before the branch was squashed into a single clean commit. #495 contains identical changes with passing CI on all platforms.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant