Skip to content

feat: multiple URIs per handler (setUri/addUri/clearUris) - #496

Draft
mathieucarbou wants to merge 1 commit into
mainfrom
feat/routing_table
Draft

mathieucarbou wants to merge 1 commit into
mainfrom
feat/routing_table

Conversation

@mathieucarbou

Copy link
Copy Markdown
Member

Summary

A handler can now serve multiple URIs and accept multiple HTTP methods by holding a list of matchers instead of a single one.

Method Effect
handler.setUri(matcher) Replace the list with one matcher (backward-compatible)
handler.addUri(matcher) Append an additional URI matcher
handler.clearUris() Remove all URI matchers

setMethod() accepts a composite mask — one method mask applies to all URIs of a handler:

handler.setMethod(HTTP_GET | HTTP_POST);  // accepts both GET and POST on all URIs

When different methods need different callbacks (e.g. POST with onBody/onUpload), use separate handler objects.

Applies to both AsyncCallbackWebHandler and AsyncCallbackJsonWebHandler.

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.

Instead of introducing a server-side routing table (addRoute()), this PR moves URI matching to a list inside the handler. The handler declares what URIs it serves — no parallel routing data structure in the server, no split-brain matching logic.

Usage

One Handler, Multiple URIs

static AsyncWebServer server(80);
static AsyncCallbackWebHandler greetHandler;

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

  // Same handler object serving three different URIs:
  greetHandler.setUri(AsyncURIMatcher::exact("/hello"));
  greetHandler.addUri(AsyncURIMatcher::exact("/hi"));
  greetHandler.addUri(AsyncURIMatcher::prefix("/greet/"));

  server.addHandler(greetHandler);  // non-owning, static RAM
  server.begin();
}

Multiple URIs × Multiple Methods

static AsyncCallbackWebHandler apiHandler;
apiHandler.setMethod(HTTP_GET | HTTP_POST | HTTP_PUT);
apiHandler.setUri(AsyncURIMatcher::exact("/api/data"));
apiHandler.addUri(AsyncURIMatcher::exact("/api/status"));
apiHandler.onRequest([](AsyncWebServerRequest *request) {
  request->send(200, "application/json", "{\"ok\":true}");
});
server.addHandler(apiHandler);
// Serves: GET/POST/PUT /api/data  and  GET/POST/PUT /api/status

Different Methods Need Different Callbacks → Different Handlers

static AsyncCallbackWebHandler getHandler;
getHandler.setMethod(HTTP_GET);
getHandler.setUri(AsyncURIMatcher::exact("/data"));
getHandler.onRequest([](AsyncWebServerRequest *req) { req->send(200, "text/plain", "GET"); });
server.addHandler(getHandler);

static AsyncCallbackWebHandler postHandler;
postHandler.setMethod(HTTP_POST);
postHandler.setUri(AsyncURIMatcher::exact("/data"));
postHandler.onRequest([](AsyncWebServerRequest *req) { req->send(200, "text/plain", "POST"); });
postHandler.onBody([](AsyncWebServerRequest *req, uint8_t *data, size_t len, size_t index, size_t total) {
  // handle request body
});
server.addHandler(postHandler);

PSRAM Handler on Multiple URIs (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 *req) { req->send(200, "application/json", "{\"ok\":true}"); });
handler->setMethod(HTTP_GET);
handler->setUri(AsyncURIMatcher::prefix("/api/"));
handler->addUri(AsyncURIMatcher::exact("/status"));
server.addHandler(*handler);  // non-owning borrow

Backward Compatibility

Fully backward compatible. Existing code using server.on(), addHandler(), serveStatic(), and single-URI handlers (setUri()) works identically — no migration required.

Changes

File Change
src/WebHandlerImpl.h AsyncCallbackWebHandler::_uri → std::list<AsyncURIMatcher> _uris; new addUri() / clearUris()
src/WebHandlers.cpp setUri() clears+pushes; canHandle() iterates _uris
src/AsyncJson.h AsyncCallbackJsonWebHandler::_uri → _uris; inline setUri()/addUri()/clearUris()
src/AsyncJson.cpp Constructor pushes URI into _uris; canHandle() iterates _uris
examples/arduino/MultiRoute/MultiRoute.ino New example
docs/routing.md New "Multiple URIs per Handler" section
docs/configuration.md New "Multiple URIs per Handler" section
docs/concepts.md Handler concept updated
platformio.ini Add MultiRoute example

Documentation

A handler can now serve multiple URIs and accept multiple HTTP methods
by holding a list of matchers instead of a single one:
- setUri() replaces the list with one matcher (backward-compatible)
- addUri() appends an additional URI matcher
- clearUris() removes all matchers

setMethod() accepts a composite mask (HTTP_GET | HTTP_POST) that applies
to all URIs of a handler. Use separate handlers when different methods
need different callbacks (onBody/onUpload).

Applies to both AsyncCallbackWebHandler and AsyncCallbackJsonWebHandler.

- New MultiRoute example: multiple URIs x composite methods, separate
  handlers for callbacks
- Updated docs: routing.md (Multiple URIs per Handler section),
  configuration.md, concepts.md

Fully backward compatible: existing code using on(), addHandler(),
serveStatic(), and single-URI handlers works identically.

Motivation from #468 discussion.

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

Copy link
Copy Markdown
Member Author

Note: most users having only 1 url matcher per handler would pay the cost of a std::list instead of just 1 uri field.
Even if I can see some uses cases for that (like memory improvements in some cases or easier wiring to avoid defining a common function, etc), do you see other use cases @inF1704 that you encounter ? How much do you need to duplicate handlers ?

@willmmiles

willmmiles commented Oct 16, 2026 •

Copy link
Copy Markdown

Note: most users having only 1 url matcher per handler would pay the cost of a std::list instead of just 1 uri field.

Yup - not just a memory space cost (should be ~4 dwords total), it's also an indirection cost (ie. the handler object is now storing a pointer to the matcher, instead of the matcher being a fixed offset from the handler pointer).

Given that canHandle() is already virtual, instead of making this a tax on all use cases, could we instead implement the list-match feature as a wrapper that overrides canHandle()?

template<typename handler_t>
class AsyncMultiURL : public handler_t {
  private:
    std::list<AsyncURIMatcher> _uris;
      
  public:
    AsyncMultiURL() : handler_t() {
       // We manage the URIs at this layer, so the wrapped handler should match everything
       handler_t::setURI(AsyncURIMatcher::all());
    }
          
    void setUri(AsyncURIMatcher uri) {
      _uris.clear();
      _uris.push_back(std::move(uri));
    }
    
    void addUri(AsyncURIMatcher uri) {
      _uris.push_back(std::move(uri));
    }
    
    void clearUris() {
      _uris.clear();
    }
    
    bool canHandle(AsyncWebServerRequest *request) const {
      if (handler_t::canHandle(request)) {  // Check other properties
        // Match against any of the registered URI matchers
        for (const auto &uri : _uris) {
          if (uri.matches(request)) {
            return true;
          }
        }
      }
      return false;
    }
};

// Usage
void setup() {
  /// ...
  static AsyncMultiURL<AsyncCallbackWebHandler> greetHandler;

  // --- 1. One handler, three URIs, two methods (static RAM) ------------------
  // The handler only defines WHAT to do — its URI list defines WHERE.
  greetHandler.onRequest([](AsyncWebServerRequest *request) {
    String msg = "Hello from " + request->url();
    request->send(200, "text/plain", msg);
  });
  // setMethod accepts a composite: this handler serves GET and POST
  greetHandler.setMethod(HTTP_GET | HTTP_POST);

  // Same handler object serving multiple URIs:
  greetHandler.setUri(AsyncURIMatcher::exact("/hello"));
  greetHandler.addUri(AsyncURIMatcher::exact("/hi"));
  greetHandler.addUri(AsyncURIMatcher::prefix("/greet/"));

  // Non-owning registration (by reference) — greetHandler lives in static RAM (.bss)
  server.addHandler(greetHandler);
}  

(code above sketched in reply box and totally untested)

@mathieucarbou

Copy link
Copy Markdown
Member Author

Note: most users having only 1 url matcher per handler would pay the cost of a std::list instead of just 1 uri field.

Yup - not just a memory space cost (should be ~4 dwords total), it's also an indirection cost (ie. the handler object is now storing a pointer to the matcher, instead of the matcher being a fixed offset from the handler pointer).

Given that canHandle() is already virtual, instead of making this a tax on all use cases, could we instead implement the list-match feature as a wrapper that overrides canHandle()?

template<typename handler_t>
class AsyncMultiURL : public handler_t {
  private:
    std::list<AsyncURIMatcher> _uris;
      
  public:
    AsyncMultiURL() : handler_t() {
       // We manage the URIs at this layer, so the wrapped handler should match everything
       handler_t::setURI(AsyncURIMatcher::all());
    }
          
    void setUri(AsyncURIMatcher uri) {
      _uris.clear();
      _uris.push_back(std::move(uri));
    }
    
    void addUri(AsyncURIMatcher uri) {
      _uris.push_back(std::move(uri));
    }
    
    void clearUris() {
      _uris.clear();
    }
    
    bool canHandle(AsyncWebServerRequest *request) const {
      if (handler_t::canHandle(request)) {  // Check other properties
        // Match against any of the registered URI matchers
        for (const auto &uri : _uris) {
          if (uri.matches(request)) {
            return true;
          }
        }
      }
      return false;
    }
};

// Usage
void setup() {
  /// ...
  static AsyncMultiURL<AsyncCallbackWebHandler> greetHandler;

  // --- 1. One handler, three URIs, two methods (static RAM) ------------------
  // The handler only defines WHAT to do — its URI list defines WHERE.
  greetHandler.onRequest([](AsyncWebServerRequest *request) {
    String msg = "Hello from " + request->url();
    request->send(200, "text/plain", msg);
  });
  // setMethod accepts a composite: this handler serves GET and POST
  greetHandler.setMethod(HTTP_GET | HTTP_POST);

  // Same handler object serving multiple URIs:
  greetHandler.setUri(AsyncURIMatcher::exact("/hello"));
  greetHandler.addUri(AsyncURIMatcher::exact("/hi"));
  greetHandler.addUri(AsyncURIMatcher::prefix("/greet/"));

  // Non-owning registration (by reference) — greetHandler lives in static RAM (.bss)
  server.addHandler(greetHandler);
}  

(code above sketched in reply box and totally untested)

I like that!

another first implementation I got was a routing table in the main class containing a list of struct composed of a pointer to a handler in the handler list and a uri matcher.

this list would need to be kept in sync with the handler list though but it wouldn’t add too much memory overhead.

@mathieucarbou
mathieucarbou marked this pull request as draft October 16, 2026 07:09
@willmmiles

Copy link
Copy Markdown

another first implementation I got was a routing table in the main class containing a list of struct composed of a pointer to a handler in the handler list and a uri matcher.

this list would need to be kept in sync with the handler list though but it wouldn’t add too much memory overhead.

Yeah, I saw it go by when it was accidentally posted earlier. I really liked the idea, especially because separating the URL and method matching from the handler objects is a cleaner design, and would move towards const or constexpr allocation of both matcher and handler. Unfortunately I don't know if it would be possible to be backwards compatible with the current API without essentially having to run both systems in parallel, especially given the extremely general canHandle() vfunction.

This branch has not been deployed

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants