diff --git a/README.md b/README.md index 34909b6..f7abebb 100644 --- a/README.md +++ b/README.md @@ -307,6 +307,39 @@ int main(void) `Post`, `Put`, `Patch`, `Delete` and `Options` methods are also supported. +### Custom HTTP methods + +Methods outside the built-in set are rejected with `400 Bad Request` unless a handler is registered for them with `CustomRoute`. This covers the WebDAV methods of RFC 4918, `SUBSCRIBE` and friends from UPnP, and any other extension method. + +```c++ +svr.CustomRoute("PROPFIND", "/dav/:id", [](const Request& req, Response& res) { + // The request body is available as usual + auto id = req.path_params.at("id"); + res.status = StatusCode::MultiStatus_207; + res.set_content(build_multistatus(req.body), "application/xml"); +}); + +// A content reader overload is available too +svr.CustomRoute("REPORT", "/dav/.*", + [](const Request& req, Response& res, + const ContentReader& content_reader) { + content_reader([&](const char* data, size_t data_length) { + // ... + return true; + }); + }); +``` + +Patterns work exactly as they do for `Get` and the other methods, so regular expressions and path parameters are both available. + +Note the following: + +* The method name must be a valid HTTP method token (RFC 9110) and must be registered before `listen()` is called. +* `GET`, `HEAD`, `POST`, `PUT`, `DELETE`, `CONNECT`, `OPTIONS`, `TRACE`, `PATCH` and `PRI` cannot be registered this way. Use the dedicated methods above instead. +* A rejected registration makes `is_valid()` return `false`, and `listen()` then fails rather than starting a server with a route that would never fire. +* Static file serving and WebSocket upgrades remain `GET`/`HEAD` only. +* `Allow` and the WebDAV `DAV:` header are not generated automatically. Register an `Options` handler if clients need them. + ### Bind a socket to multiple interfaces and any available port ```cpp diff --git a/docs-src/pages/en/cookbook/e01-sse-server.md b/docs-src/pages/en/cookbook/e01-sse-server.md index 400be04..69054cb 100644 --- a/docs-src/pages/en/cookbook/e01-sse-server.md +++ b/docs-src/pages/en/cookbook/e01-sse-server.md @@ -1,6 +1,6 @@ --- title: "E01. Implement an SSE Server" -order: 47 +order: 48 status: "draft" --- diff --git a/docs-src/pages/en/cookbook/e02-sse-event-names.md b/docs-src/pages/en/cookbook/e02-sse-event-names.md index b52a65f..1795b22 100644 --- a/docs-src/pages/en/cookbook/e02-sse-event-names.md +++ b/docs-src/pages/en/cookbook/e02-sse-event-names.md @@ -1,6 +1,6 @@ --- title: "E02. Use Named Events in SSE" -order: 48 +order: 49 status: "draft" --- diff --git a/docs-src/pages/en/cookbook/e03-sse-reconnect.md b/docs-src/pages/en/cookbook/e03-sse-reconnect.md index 79f2d46..cf69e81 100644 --- a/docs-src/pages/en/cookbook/e03-sse-reconnect.md +++ b/docs-src/pages/en/cookbook/e03-sse-reconnect.md @@ -1,6 +1,6 @@ --- title: "E03. Handle SSE Reconnection" -order: 49 +order: 50 status: "draft" --- diff --git a/docs-src/pages/en/cookbook/e04-sse-client.md b/docs-src/pages/en/cookbook/e04-sse-client.md index 9317673..2199a5a 100644 --- a/docs-src/pages/en/cookbook/e04-sse-client.md +++ b/docs-src/pages/en/cookbook/e04-sse-client.md @@ -1,6 +1,6 @@ --- title: "E04. Receive SSE on the Client" -order: 50 +order: 51 status: "draft" --- diff --git a/docs-src/pages/en/cookbook/index.md b/docs-src/pages/en/cookbook/index.md index 097552b..f2a6696 100644 --- a/docs-src/pages/en/cookbook/index.md +++ b/docs-src/pages/en/cookbook/index.md @@ -73,6 +73,9 @@ A collection of recipes that answer "How do I...?" questions. Each recipe is sel - [S21. Configure the thread pool](s21-thread-pool) - [S22. Talk over a Unix domain socket](s22-unix-socket) +### Protocol Extensions +- [S23. Handle custom HTTP methods](s23-custom-methods) + ## TLS / Security - [T01. Choosing between OpenSSL, mbedTLS, and wolfSSL](t01-tls-backends) diff --git a/docs-src/pages/en/cookbook/s01-handlers.md b/docs-src/pages/en/cookbook/s01-handlers.md index 7f27287..3ad4af8 100644 --- a/docs-src/pages/en/cookbook/s01-handlers.md +++ b/docs-src/pages/en/cookbook/s01-handlers.md @@ -4,7 +4,7 @@ order: 20 status: "draft" --- -With `httplib::Server`, you register a handler per HTTP method. Just pass a pattern and a lambda to `Get()`, `Post()`, `Put()`, or `Delete()`. +With `httplib::Server`, you register a handler per HTTP method. Just pass a pattern and a lambda to `Get()`, `Post()`, `Put()`, or `Delete()`. For methods outside the built-in set, such as WebDAV's `PROPFIND`, use `CustomRoute()`. ## Basic usage @@ -64,3 +64,5 @@ To add a response header, use `res.set_header("Name", "Value")`. > **Note:** `listen()` is a blocking call. To run it on a different thread, wrap it in `std::thread`. If you need non-blocking startup, see [S18. Control startup order with `listen_after_bind`](../s18-listen-after-bind). > To use path parameters like `/users/:id`, see [S03. Use path parameters](../s03-path-params). + +> For methods outside the built-in set, such as WebDAV's `PROPFIND`, see [S23. Handle custom HTTP methods](../s23-custom-methods). diff --git a/docs-src/pages/en/cookbook/s23-custom-methods.md b/docs-src/pages/en/cookbook/s23-custom-methods.md new file mode 100644 index 0000000..c2b8513 --- /dev/null +++ b/docs-src/pages/en/cookbook/s23-custom-methods.md @@ -0,0 +1,59 @@ +--- +title: "S23. Handle custom HTTP methods" +order: 42 +status: "draft" +--- + +The server rejects HTTP methods it does not know with `400 Bad Request`. To accept an extension method, such as the WebDAV methods of RFC 4918 (`PROPFIND`, `PROPPATCH`, `MKCOL` and friends) or UPnP's `SUBSCRIBE`, register a handler with `CustomRoute()`. Registering the handler is what makes the server accept the method. + +## Basic usage + +```cpp +svr.CustomRoute("PROPFIND", "/dav/:id", + [](const httplib::Request &req, httplib::Response &res) { + // The request body is available as usual + auto id = req.path_params.at("id"); + res.status = httplib::StatusCode::MultiStatus_207; + res.set_content(build_multistatus(req.body), "application/xml"); + }); +``` + +Patterns work the same way as they do for `Get()`. Regular expressions and path parameters are both available. + +## Advertise your methods with OPTIONS + +A WebDAV client asks the server about its capabilities with `OPTIONS` before doing anything else. cpp-httplib generates neither the `DAV:` header nor `Allow`, so return them yourself. Forget this and clients will turn you away even though your `PROPFIND` works. + +```cpp +svr.Options("/dav/.*", [](const httplib::Request &req, httplib::Response &res) { + res.set_header("DAV", "1"); + res.set_header("Allow", "OPTIONS, GET, HEAD, PROPFIND, PROPPATCH, MKCOL"); +}); +``` + +## Read the body as a stream + +There is a content reader overload, just like the one on `Post()`. Use it when you would rather not hold a large XML document in memory all at once. + +```cpp +svr.CustomRoute("REPORT", "/dav/.*", + [](const httplib::Request &req, httplib::Response &res, + const httplib::ContentReader &content_reader) { + content_reader([&](const char *data, size_t data_length) { + // Process it a chunk at a time + return true; + }); + res.status = httplib::StatusCode::MultiStatus_207; + }); +``` + +## Things to keep in mind + +- The method name has to be a valid HTTP method token (RFC 9110), and it must be registered before you call `listen()` +- `GET`, `HEAD`, `POST`, `PUT`, `DELETE`, `CONNECT`, `OPTIONS`, `TRACE`, `PATCH` and `PRI` cannot be registered here. Use the dedicated methods for those +- A rejected registration makes `is_valid()` return `false` and `listen()` fail, so the server never starts holding a handler that would never run +- Static file serving and WebSocket upgrades stay `GET`/`HEAD` only + +> **Note:** cpp-httplib takes you as far as routing the method. If you want to call it WebDAV, generating the `207 Multi-Status` XML, interpreting the `Depth` header and managing locks are all yours to implement. The protocol itself lives outside the library. + +> For the basics of registering handlers, see [S01. Register GET / POST / PUT / DELETE handlers](../s01-handlers). diff --git a/docs-src/pages/en/cookbook/t01-tls-backends.md b/docs-src/pages/en/cookbook/t01-tls-backends.md index 91e4496..f4a2fad 100644 --- a/docs-src/pages/en/cookbook/t01-tls-backends.md +++ b/docs-src/pages/en/cookbook/t01-tls-backends.md @@ -1,6 +1,6 @@ --- title: "T01. Choosing Between OpenSSL, mbedTLS, and wolfSSL" -order: 42 +order: 43 status: "draft" --- diff --git a/docs-src/pages/en/cookbook/t02-cert-verification.md b/docs-src/pages/en/cookbook/t02-cert-verification.md index d4ed118..603c76b 100644 --- a/docs-src/pages/en/cookbook/t02-cert-verification.md +++ b/docs-src/pages/en/cookbook/t02-cert-verification.md @@ -1,6 +1,6 @@ --- title: "T02. Control SSL Certificate Verification" -order: 43 +order: 44 status: "draft" --- diff --git a/docs-src/pages/en/cookbook/t03-ssl-server.md b/docs-src/pages/en/cookbook/t03-ssl-server.md index ac2bea7..52cb44a 100644 --- a/docs-src/pages/en/cookbook/t03-ssl-server.md +++ b/docs-src/pages/en/cookbook/t03-ssl-server.md @@ -1,6 +1,6 @@ --- title: "T03. Start an SSL/TLS Server" -order: 44 +order: 45 status: "draft" --- diff --git a/docs-src/pages/en/cookbook/t04-mtls.md b/docs-src/pages/en/cookbook/t04-mtls.md index 6558b64..4224fb1 100644 --- a/docs-src/pages/en/cookbook/t04-mtls.md +++ b/docs-src/pages/en/cookbook/t04-mtls.md @@ -1,6 +1,6 @@ --- title: "T04. Configure mTLS" -order: 45 +order: 46 status: "draft" --- diff --git a/docs-src/pages/en/cookbook/t05-peer-cert.md b/docs-src/pages/en/cookbook/t05-peer-cert.md index c11f52d..a89eee1 100644 --- a/docs-src/pages/en/cookbook/t05-peer-cert.md +++ b/docs-src/pages/en/cookbook/t05-peer-cert.md @@ -1,6 +1,6 @@ --- title: "T05. Access the Peer Certificate on the Server Side" -order: 46 +order: 47 status: "draft" --- diff --git a/docs-src/pages/en/cookbook/w01-websocket-echo.md b/docs-src/pages/en/cookbook/w01-websocket-echo.md index e6f9b9d..0ff515d 100644 --- a/docs-src/pages/en/cookbook/w01-websocket-echo.md +++ b/docs-src/pages/en/cookbook/w01-websocket-echo.md @@ -1,6 +1,6 @@ --- title: "W01. Implement a WebSocket Echo Server and Client" -order: 51 +order: 52 status: "draft" --- diff --git a/docs-src/pages/en/cookbook/w02-websocket-ping.md b/docs-src/pages/en/cookbook/w02-websocket-ping.md index 8332810..3348dea 100644 --- a/docs-src/pages/en/cookbook/w02-websocket-ping.md +++ b/docs-src/pages/en/cookbook/w02-websocket-ping.md @@ -1,6 +1,6 @@ --- title: "W02. Set a WebSocket Heartbeat" -order: 52 +order: 53 status: "draft" --- diff --git a/docs-src/pages/en/cookbook/w03-websocket-close.md b/docs-src/pages/en/cookbook/w03-websocket-close.md index 0fef2c5..da5e829 100644 --- a/docs-src/pages/en/cookbook/w03-websocket-close.md +++ b/docs-src/pages/en/cookbook/w03-websocket-close.md @@ -1,6 +1,6 @@ --- title: "W03. Handle Connection Close" -order: 53 +order: 54 status: "draft" --- diff --git a/docs-src/pages/en/cookbook/w04-websocket-binary.md b/docs-src/pages/en/cookbook/w04-websocket-binary.md index dc8dbfd..fbdde41 100644 --- a/docs-src/pages/en/cookbook/w04-websocket-binary.md +++ b/docs-src/pages/en/cookbook/w04-websocket-binary.md @@ -1,6 +1,6 @@ --- title: "W04. Send and Receive Binary Frames" -order: 54 +order: 55 status: "draft" --- diff --git a/docs-src/pages/en/cookbook/w05-websocket-tls.md b/docs-src/pages/en/cookbook/w05-websocket-tls.md index 34fa609..d309faf 100644 --- a/docs-src/pages/en/cookbook/w05-websocket-tls.md +++ b/docs-src/pages/en/cookbook/w05-websocket-tls.md @@ -1,6 +1,6 @@ --- title: "W05. Configure TLS for wss:// Connections" -order: 55 +order: 56 status: "draft" --- diff --git a/docs-src/pages/en/cookbook/w06-websocket-timeouts.md b/docs-src/pages/en/cookbook/w06-websocket-timeouts.md index 0161959..3ea3796 100644 --- a/docs-src/pages/en/cookbook/w06-websocket-timeouts.md +++ b/docs-src/pages/en/cookbook/w06-websocket-timeouts.md @@ -1,6 +1,6 @@ --- title: "W06. Set Timeouts" -order: 56 +order: 57 status: "draft" --- diff --git a/docs-src/pages/ja/cookbook/e01-sse-server.md b/docs-src/pages/ja/cookbook/e01-sse-server.md index 6908d2c..b926b94 100644 --- a/docs-src/pages/ja/cookbook/e01-sse-server.md +++ b/docs-src/pages/ja/cookbook/e01-sse-server.md @@ -1,6 +1,6 @@ --- title: "E01. SSEサーバーを実装する" -order: 47 +order: 48 status: "draft" --- diff --git a/docs-src/pages/ja/cookbook/e02-sse-event-names.md b/docs-src/pages/ja/cookbook/e02-sse-event-names.md index 20d5648..b1dae29 100644 --- a/docs-src/pages/ja/cookbook/e02-sse-event-names.md +++ b/docs-src/pages/ja/cookbook/e02-sse-event-names.md @@ -1,6 +1,6 @@ --- title: "E02. SSEでイベント名を使い分ける" -order: 48 +order: 49 status: "draft" --- diff --git a/docs-src/pages/ja/cookbook/e03-sse-reconnect.md b/docs-src/pages/ja/cookbook/e03-sse-reconnect.md index 466fa21..a3da4b5 100644 --- a/docs-src/pages/ja/cookbook/e03-sse-reconnect.md +++ b/docs-src/pages/ja/cookbook/e03-sse-reconnect.md @@ -1,6 +1,6 @@ --- title: "E03. SSEの再接続を処理する" -order: 49 +order: 50 status: "draft" --- diff --git a/docs-src/pages/ja/cookbook/e04-sse-client.md b/docs-src/pages/ja/cookbook/e04-sse-client.md index 25f0162..bf13f91 100644 --- a/docs-src/pages/ja/cookbook/e04-sse-client.md +++ b/docs-src/pages/ja/cookbook/e04-sse-client.md @@ -1,6 +1,6 @@ --- title: "E04. SSEをクライアントで受信する" -order: 50 +order: 51 status: "draft" --- diff --git a/docs-src/pages/ja/cookbook/index.md b/docs-src/pages/ja/cookbook/index.md index d761e8a..55ce314 100644 --- a/docs-src/pages/ja/cookbook/index.md +++ b/docs-src/pages/ja/cookbook/index.md @@ -73,6 +73,9 @@ status: "draft" - [S21. マルチスレッド数を設定する](s21-thread-pool) - [S22. Unix domain socketで通信する](s22-unix-socket) +### プロトコル拡張 +- [S23. カスタムHTTPメソッドを扱う](s23-custom-methods) + ## TLS / セキュリティ - [T01. OpenSSL・mbedTLS・wolfSSLの選択指針](t01-tls-backends) diff --git a/docs-src/pages/ja/cookbook/s01-handlers.md b/docs-src/pages/ja/cookbook/s01-handlers.md index 62120ed..2b46d5b 100644 --- a/docs-src/pages/ja/cookbook/s01-handlers.md +++ b/docs-src/pages/ja/cookbook/s01-handlers.md @@ -4,7 +4,7 @@ order: 20 status: "draft" --- -`httplib::Server`では、HTTPメソッドごとにハンドラを登録します。`Get()`、`Post()`、`Put()`、`Delete()`の各メソッドにパターンとラムダを渡すだけです。 +`httplib::Server`では、HTTPメソッドごとにハンドラを登録します。`Get()`、`Post()`、`Put()`、`Delete()`の各メソッドにパターンとラムダを渡すだけです。WebDAVの`PROPFIND`のような組み込み以外のメソッドを扱いたいときは、`CustomRoute()`を使います。 ## 基本の使い方 @@ -64,3 +64,5 @@ svr.Get("/me", [](const httplib::Request &req, httplib::Response &res) { > **Note:** `listen()`はブロックする関数です。別スレッドで動かしたいときは`std::thread`で包むか、ノンブロッキング起動が必要なら[S18. `listen_after_bind`で起動順序を制御する](../s18-listen-after-bind)を参照してください。 > パスパラメーター(`/users/:id`)を使いたい場合は[S03. パスパラメーターを使う](../s03-path-params)を参照してください。 + +> WebDAVの`PROPFIND`のような組み込み以外のメソッドは[S23. カスタムHTTPメソッドを扱う](../s23-custom-methods)を参照してください。 diff --git a/docs-src/pages/ja/cookbook/s23-custom-methods.md b/docs-src/pages/ja/cookbook/s23-custom-methods.md new file mode 100644 index 0000000..e9766ce --- /dev/null +++ b/docs-src/pages/ja/cookbook/s23-custom-methods.md @@ -0,0 +1,59 @@ +--- +title: "S23. カスタムHTTPメソッドを扱う" +order: 42 +status: "draft" +--- + +サーバーは知らないHTTPメソッドを`400 Bad Request`で弾きます。RFC 4918のWebDAVメソッド(`PROPFIND`、`PROPPATCH`、`MKCOL`など)やUPnPの`SUBSCRIBE`のような拡張メソッドを受け付けたいときは、`CustomRoute()`でハンドラを登録してください。登録したことがそのまま「このメソッドを受け付ける」という意味になります。 + +## 基本の使い方 + +```cpp +svr.CustomRoute("PROPFIND", "/dav/:id", + [](const httplib::Request &req, httplib::Response &res) { + // リクエストボディも通常どおり読める + auto id = req.path_params.at("id"); + res.status = httplib::StatusCode::MultiStatus_207; + res.set_content(build_multistatus(req.body), "application/xml"); + }); +``` + +パターンの書き方は`Get()`などと同じです。正規表現もパスパラメーターもそのまま使えます。 + +## OPTIONSで対応メソッドを知らせる + +WebDAVクライアントは接続すると、まず`OPTIONS`でサーバーの能力を問い合わせます。cpp-httplibは`DAV:`ヘッダーも`Allow`ヘッダーも自動生成しないので、自分で返してください。ここを忘れると、`PROPFIND`が正しく動いてもクライアントに拒否されます。 + +```cpp +svr.Options("/dav/.*", [](const httplib::Request &req, httplib::Response &res) { + res.set_header("DAV", "1"); + res.set_header("Allow", "OPTIONS, GET, HEAD, PROPFIND, PROPPATCH, MKCOL"); +}); +``` + +## ボディをストリーミングで受け取る + +`Post()`などと同じく、Content Reader版のオーバーロードがあります。大きなXMLを一度にメモリへ載せたくないときに使ってください。 + +```cpp +svr.CustomRoute("REPORT", "/dav/.*", + [](const httplib::Request &req, httplib::Response &res, + const httplib::ContentReader &content_reader) { + content_reader([&](const char *data, size_t data_length) { + // 少しずつ処理する + return true; + }); + res.status = httplib::StatusCode::MultiStatus_207; + }); +``` + +## 覚えておくこと + +- メソッド名はHTTPのトークン(RFC 9110)である必要があります。`listen()`より前に登録してください +- `GET`、`HEAD`、`POST`、`PUT`、`DELETE`、`CONNECT`、`OPTIONS`、`TRACE`、`PATCH`、`PRI`は登録できません。これらには専用のメソッドを使ってください +- 登録が拒否されると`is_valid()`が`false`になり、`listen()`が失敗します。呼ばれないハンドラを抱えたままサーバーが起動することはありません +- 静的ファイルの配信とWebSocketのアップグレードは`GET`/`HEAD`のままです + +> **Note:** cpp-httplibが用意するのはメソッドのルーティングまでです。WebDAVを名乗るなら、`207 Multi-Status`のXML生成、`Depth`ヘッダーの解釈、ロックの管理は自分で実装することになります。プロトコルの本体はライブラリの外側です。 + +> ハンドラ登録の基本は[S01. GET / POST / PUT / DELETEハンドラを登録する](../s01-handlers)を参照してください。 diff --git a/docs-src/pages/ja/cookbook/t01-tls-backends.md b/docs-src/pages/ja/cookbook/t01-tls-backends.md index 4d40e23..eae732e 100644 --- a/docs-src/pages/ja/cookbook/t01-tls-backends.md +++ b/docs-src/pages/ja/cookbook/t01-tls-backends.md @@ -1,6 +1,6 @@ --- title: "T01. OpenSSL・mbedTLS・wolfSSLの選択指針" -order: 42 +order: 43 status: "draft" --- diff --git a/docs-src/pages/ja/cookbook/t02-cert-verification.md b/docs-src/pages/ja/cookbook/t02-cert-verification.md index 6fe716b..244576c 100644 --- a/docs-src/pages/ja/cookbook/t02-cert-verification.md +++ b/docs-src/pages/ja/cookbook/t02-cert-verification.md @@ -1,6 +1,6 @@ --- title: "T02. SSL証明書の検証を制御する" -order: 43 +order: 44 status: "draft" --- diff --git a/docs-src/pages/ja/cookbook/t03-ssl-server.md b/docs-src/pages/ja/cookbook/t03-ssl-server.md index 98695eb..b924337 100644 --- a/docs-src/pages/ja/cookbook/t03-ssl-server.md +++ b/docs-src/pages/ja/cookbook/t03-ssl-server.md @@ -1,6 +1,6 @@ --- title: "T03. SSL/TLSサーバーを立ち上げる" -order: 44 +order: 45 status: "draft" --- diff --git a/docs-src/pages/ja/cookbook/t04-mtls.md b/docs-src/pages/ja/cookbook/t04-mtls.md index f41199f..2c2e5ae 100644 --- a/docs-src/pages/ja/cookbook/t04-mtls.md +++ b/docs-src/pages/ja/cookbook/t04-mtls.md @@ -1,6 +1,6 @@ --- title: "T04. mTLSを設定する" -order: 45 +order: 46 status: "draft" --- diff --git a/docs-src/pages/ja/cookbook/t05-peer-cert.md b/docs-src/pages/ja/cookbook/t05-peer-cert.md index 5464084..7a064d9 100644 --- a/docs-src/pages/ja/cookbook/t05-peer-cert.md +++ b/docs-src/pages/ja/cookbook/t05-peer-cert.md @@ -1,6 +1,6 @@ --- title: "T05. サーバー側でピア証明書を参照する" -order: 46 +order: 47 status: "draft" --- diff --git a/docs-src/pages/ja/cookbook/w01-websocket-echo.md b/docs-src/pages/ja/cookbook/w01-websocket-echo.md index d7a38f9..0dcea3f 100644 --- a/docs-src/pages/ja/cookbook/w01-websocket-echo.md +++ b/docs-src/pages/ja/cookbook/w01-websocket-echo.md @@ -1,6 +1,6 @@ --- title: "W01. WebSocketエコーサーバー/クライアントを実装する" -order: 51 +order: 52 status: "draft" --- diff --git a/docs-src/pages/ja/cookbook/w02-websocket-ping.md b/docs-src/pages/ja/cookbook/w02-websocket-ping.md index 8f43eaf..7190e4e 100644 --- a/docs-src/pages/ja/cookbook/w02-websocket-ping.md +++ b/docs-src/pages/ja/cookbook/w02-websocket-ping.md @@ -1,6 +1,6 @@ --- title: "W02. ハートビートを設定する" -order: 52 +order: 53 status: "draft" --- diff --git a/docs-src/pages/ja/cookbook/w03-websocket-close.md b/docs-src/pages/ja/cookbook/w03-websocket-close.md index a11d5c5..9cb6e19 100644 --- a/docs-src/pages/ja/cookbook/w03-websocket-close.md +++ b/docs-src/pages/ja/cookbook/w03-websocket-close.md @@ -1,6 +1,6 @@ --- title: "W03. 接続クローズをハンドリングする" -order: 53 +order: 54 status: "draft" --- diff --git a/docs-src/pages/ja/cookbook/w04-websocket-binary.md b/docs-src/pages/ja/cookbook/w04-websocket-binary.md index 01bd500..dad12e1 100644 --- a/docs-src/pages/ja/cookbook/w04-websocket-binary.md +++ b/docs-src/pages/ja/cookbook/w04-websocket-binary.md @@ -1,6 +1,6 @@ --- title: "W04. バイナリフレームを送受信する" -order: 54 +order: 55 status: "draft" --- diff --git a/docs-src/pages/ja/cookbook/w05-websocket-tls.md b/docs-src/pages/ja/cookbook/w05-websocket-tls.md index ca9bdf9..5c8b0be 100644 --- a/docs-src/pages/ja/cookbook/w05-websocket-tls.md +++ b/docs-src/pages/ja/cookbook/w05-websocket-tls.md @@ -1,6 +1,6 @@ --- title: "W05. wss接続でTLSを設定する" -order: 55 +order: 56 status: "draft" --- diff --git a/docs-src/pages/ja/cookbook/w06-websocket-timeouts.md b/docs-src/pages/ja/cookbook/w06-websocket-timeouts.md index ccb80ee..6653e93 100644 --- a/docs-src/pages/ja/cookbook/w06-websocket-timeouts.md +++ b/docs-src/pages/ja/cookbook/w06-websocket-timeouts.md @@ -1,6 +1,6 @@ --- title: "W06. タイムアウトを設定する" -order: 56 +order: 57 status: "draft" --- diff --git a/httplib.h b/httplib.h index d5a1755..539cc3d 100644 --- a/httplib.h +++ b/httplib.h @@ -2107,6 +2107,17 @@ public: Server &Delete(const std::string &pattern, HandlerWithContentReader handler); Server &Options(const std::string &pattern, Handler handler); + // Register a handler for an HTTP method outside the built-in set (e.g. the + // WebDAV methods from RFC 4918). Registering a method here is what makes the + // server accept it; an unregistered method is still rejected with 400. + // `method` must be a valid HTTP method token and must not be one of the + // built-in methods, which have their own registration functions above. A + // rejected registration makes is_valid() return false, so listen() fails. + Server &CustomRoute(const std::string &method, const std::string &pattern, + Handler handler); + Server &CustomRoute(const std::string &method, const std::string &pattern, + HandlerWithContentReader handler); + Server &WebSocket(const std::string &pattern, WebSocketHandler handler); Server &WebSocket(const std::string &pattern, WebSocketHandler handler, SubProtocolSelector sub_protocol_selector); @@ -2226,9 +2237,21 @@ private: std::vector, HandlerWithContentReader>>; + // Both handler tables for one custom method live in a single entry, so that + // routing() needs only one map lookup per request to reach either of them. + struct CustomHandlerEntry { + Handlers handlers; + HandlersForContentReader handlers_for_content_reader; + }; + using CustomHandlers = std::map; + static std::unique_ptr make_matcher(const std::string &pattern); + static const std::set &builtin_methods(); + CustomHandlerEntry *custom_entry_for_registration(const std::string &method); + const CustomHandlerEntry *find_custom_entry(const std::string &method) const; + template Server &add_handler( std::vector, H>> &handlers, @@ -2292,6 +2315,10 @@ private: std::atomic is_running_{false}; std::atomic is_decommissioned{false}; + // Set when CustomRoute() refuses a registration. Written before listen(), + // read by is_valid() on the same thread, so it needs no synchronization. + bool has_invalid_registration_ = false; + struct MountPointEntry { std::string mount_point; std::string base_dir; @@ -2313,6 +2340,7 @@ private: Handlers delete_handlers_; HandlersForContentReader delete_handlers_for_content_reader_; Handlers options_handlers_; + CustomHandlers custom_handlers_; struct WebSocketHandlerEntry { std::unique_ptr matcher; @@ -12441,6 +12469,57 @@ inline Server &Server::Options(const std::string &pattern, Handler handler) { return add_handler(options_handlers_, pattern, std::move(handler)); } +inline const std::set &Server::builtin_methods() { + thread_local const std::set methods{ + "GET", "HEAD", "POST", "PUT", "DELETE", + "CONNECT", "OPTIONS", "TRACE", "PATCH", "PRI"}; + return methods; +} + +inline Server::CustomHandlerEntry * +Server::custom_entry_for_registration(const std::string &method) { + // Built-in methods are refused for two different reasons. GET, HEAD, POST, + // PUT, DELETE, OPTIONS and PATCH are dispatched by the if/else chain in + // routing() before the custom tables are consulted, so a route registered + // for one of them could never fire. CONNECT, TRACE and PRI have no branch + // there and would be reachable, but they carry protocol-level meaning + // (tunnel setup, request echo, the HTTP/2 connection preface) that this + // library does not route. + if (!detail::fields::is_token(method) || builtin_methods().count(method)) { + output_error_log(Error::InvalidHTTPMethod, nullptr); + has_invalid_registration_ = true; + return nullptr; + } + return &custom_handlers_[method]; +} + +inline Server &Server::CustomRoute(const std::string &method, + const std::string &pattern, + Handler handler) { + auto *entry = custom_entry_for_registration(method); + if (!entry) { return *this; } + return add_handler(entry->handlers, pattern, std::move(handler)); +} + +inline Server &Server::CustomRoute(const std::string &method, + const std::string &pattern, + HandlerWithContentReader handler) { + auto *entry = custom_entry_for_registration(method); + if (!entry) { return *this; } + return add_handler(entry->handlers_for_content_reader, pattern, + std::move(handler)); +} + +inline const Server::CustomHandlerEntry * +Server::find_custom_entry(const std::string &method) const { + // find() alone would be correct here. The empty() check is what keeps the + // per-request cost off servers that never call CustomRoute(), which is the + // overwhelmingly common case; keep it rather than walking into the tree. + if (custom_handlers_.empty()) { return nullptr; } + auto it = custom_handlers_.find(method); + return it == custom_handlers_.end() ? nullptr : &it->second; +} + inline Server &Server::WebSocket(const std::string &pattern, WebSocketHandler handler) { websocket_handlers_.push_back( @@ -12733,11 +12812,12 @@ inline bool Server::parse_request_line(const char *s, Request &req) const { if (count != 3) { return false; } } - thread_local const std::set methods{ - "GET", "HEAD", "POST", "PUT", "DELETE", - "CONNECT", "OPTIONS", "TRACE", "PATCH", "PRI"}; + // A method outside the built-in set is accepted only when a handler has been + // registered for it with CustomRoute(). + const auto &methods = builtin_methods(); - if (methods.find(req.method) == methods.end()) { + if (methods.find(req.method) == methods.end() && + !find_custom_entry(req.method)) { output_error_log(Error::InvalidHTTPMethod, &req); return false; } @@ -13372,7 +13452,14 @@ inline bool Server::routing(Request &req, Response &res, Stream &strm) { return true; } - if (detail::expect_content(req)) { + const auto *custom = find_custom_entry(req.method); + + // The second clause mirrors what expect_content() does unconditionally for + // POST/PUT/PATCH/DELETE: a content reader route fires even when the request + // carries no body. Without it a body-less PROPFIND (RFC 4918 treats one as + // `allprop`) would skip its handler and fall through to 404. + if (detail::expect_content(req) || + (custom && !custom->handlers_for_content_reader.empty())) { // Content reader handler { // Track whether the ContentReader was aborted due to the decompressed @@ -13419,6 +13506,9 @@ inline bool Server::routing(Request &req, Response &res, Stream &strm) { } else if (req.method == "DELETE") { dispatched = dispatch_request_for_content_reader( req, res, std::move(reader), delete_handlers_for_content_reader_); + } else if (custom) { + dispatched = dispatch_request_for_content_reader( + req, res, std::move(reader), custom->handlers_for_content_reader); } if (dispatched) { @@ -13455,6 +13545,8 @@ inline bool Server::routing(Request &req, Response &res, Stream &strm) { return dispatch_request(req, res, options_handlers_, strm); } else if (req.method == "PATCH") { return dispatch_request(req, res, patch_handlers_, strm); + } else if (custom) { + return dispatch_request(req, res, custom->handlers, strm); } res.status = StatusCode::BadRequest_400; @@ -13972,7 +14064,7 @@ Server::process_request(Stream &strm, const std::string &remote_addr, return ret; } -inline bool Server::is_valid() const { return true; } +inline bool Server::is_valid() const { return !has_invalid_registration_; } inline bool Server::process_and_close_socket(socket_t sock) { std::string remote_addr; @@ -17313,7 +17405,9 @@ inline SSLServer::~SSLServer() { if (ctx_) { tls::free_context(ctx_); } } -inline bool SSLServer::is_valid() const { return ctx_ != nullptr; } +inline bool SSLServer::is_valid() const { + return ctx_ != nullptr && Server::is_valid(); +} inline bool SSLServer::process_and_close_socket(socket_t sock) { using namespace tls; diff --git a/test/test.cc b/test/test.cc index b0c1773..aab580d 100644 --- a/test/test.cc +++ b/test/test.cc @@ -3981,6 +3981,19 @@ TEST(BindServerTest, BindAndListenSeparatelySSL) { svr.stop(); } +// SSLServer::is_valid() overrides the base version, so it has to chain to it +// or a rejected CustomRoute() registration would not stop the server binding. +TEST(BindServerTest, SSLServerIsInvalidAfterRejectedCustomRoute) { + SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE, CLIENT_CA_CERT_FILE, + CLIENT_CA_CERT_DIR); + ASSERT_TRUE(svr.is_valid()); + + svr.CustomRoute("GET", "/x", [](const Request &, Response &) {}); + + EXPECT_FALSE(svr.is_valid()); + EXPECT_TRUE(svr.bind_to_any_port("0.0.0.0") < 0); +} + TEST(BindServerTest, BindAndListenSeparatelySSLEncryptedKey) { SSLServer svr(SERVER_ENCRYPTED_CERT_FILE, SERVER_ENCRYPTED_PRIVATE_KEY_FILE, nullptr, nullptr, SERVER_ENCRYPTED_PRIVATE_KEY_PASS); @@ -5206,6 +5219,38 @@ protected: [&](const Request & /*req*/, Response &res) { res.set_header("Allow", "GET, POST, HEAD, OPTIONS"); }) + .CustomRoute("PROPFIND", "/dav/:id", + [&](const Request &req, Response &res) { + res.set_header("x-body-size", + std::to_string(req.body.size())); + res.set_header("x-matched-route", req.matched_route); + res.set_header("x-dav-id", req.path_params.at("id")); + res.status = StatusCode::MultiStatus_207; + res.set_content(req.body, "application/xml"); + }) + .CustomRoute("PROPFIND", R"(/dav-re/(\d+))", + [&](const Request &req, Response &res) { + res.set_header("x-dav-match", req.matches[1]); + res.status = StatusCode::MultiStatus_207; + }) + .CustomRoute("MKCOL", "/dav-mkcol", + [&](const Request &req, Response &res) { + EXPECT_TRUE(req.body.empty()); + res.status = StatusCode::Created_201; + }) + .CustomRoute( + "REPORT", "/dav-report", + [&](const Request & /*req*/, Response &res, + const ContentReader &content_reader) { + std::string body; + content_reader([&](const char *data, size_t data_length) { + body.append(data, data_length); + return true; + }); + res.set_header("x-body-size", std::to_string(body.size())); + res.status = StatusCode::MultiStatus_207; + res.set_content(body, "application/xml"); + }) .Get("/request-target", [&](const Request &req, Response & /*res*/) { EXPECT_EQ("/request-target?aaa=bbb&ccc=ddd", req.target); @@ -7994,6 +8039,176 @@ TEST_F(ServerTest, BadRequestLineCancelsKeepAlive) { EXPECT_FALSE(cli_.is_socket_open()); } +TEST_F(ServerTest, CustomRouteReadsBody) { + const std::string xml = + R"()"; + + Request req; + req.method = "PROPFIND"; + req.path = "/dav/dir"; + req.set_header("Depth", "1"); + req.body = xml; + + auto res = cli_.send(req); + + ASSERT_TRUE(res) << "Error: " << to_string(res.error()); + EXPECT_EQ(StatusCode::MultiStatus_207, res->status); + EXPECT_EQ(xml, res->body); + EXPECT_EQ(std::to_string(xml.size()), res->get_header_value("x-body-size")); + EXPECT_EQ("application/xml", res->get_header_value("Content-Type")); +} + +TEST_F(ServerTest, CustomRouteMatchedRouteAndPathParams) { + Request req; + req.method = "PROPFIND"; + req.path = "/dav/42"; + + auto res = cli_.send(req); + + ASSERT_TRUE(res) << "Error: " << to_string(res.error()); + EXPECT_EQ(StatusCode::MultiStatus_207, res->status); + EXPECT_EQ("/dav/:id", res->get_header_value("x-matched-route")); + EXPECT_EQ("42", res->get_header_value("x-dav-id")); +} + +TEST_F(ServerTest, CustomRouteRegexPattern) { + Request req; + req.method = "PROPFIND"; + req.path = "/dav-re/123"; + + auto res = cli_.send(req); + + ASSERT_TRUE(res) << "Error: " << to_string(res.error()); + EXPECT_EQ(StatusCode::MultiStatus_207, res->status); + EXPECT_EQ("123", res->get_header_value("x-dav-match")); +} + +TEST_F(ServerTest, CustomRouteWithoutBody) { + Request req; + req.method = "MKCOL"; + req.path = "/dav-mkcol"; + + auto res = cli_.send(req); + + ASSERT_TRUE(res) << "Error: " << to_string(res.error()); + EXPECT_EQ(StatusCode::Created_201, res->status); +} + +TEST_F(ServerTest, CustomRouteWithContentReader) { + const std::string xml = R"()"; + + Request req; + req.method = "REPORT"; + req.path = "/dav-report"; + req.body = xml; + + auto res = cli_.send(req); + + ASSERT_TRUE(res) << "Error: " << to_string(res.error()); + EXPECT_EQ(StatusCode::MultiStatus_207, res->status); + EXPECT_EQ(xml, res->body); +} + +// A content reader route must fire even when the request carries no body, +// the way the built-in Delete(pattern, HandlerWithContentReader) does. +// Without that, a body-less PROPFIND-style request would fall through to 404. +TEST_F(ServerTest, CustomRouteWithContentReaderWithoutBody) { + Request req; + req.method = "REPORT"; + req.path = "/dav-report"; + + auto res = cli_.send(req); + + ASSERT_TRUE(res) << "Error: " << to_string(res.error()); + EXPECT_EQ(StatusCode::MultiStatus_207, res->status); + EXPECT_EQ("0", res->get_header_value("x-body-size")); +} + +TEST_F(ServerTest, CustomRouteUnmatchedPathReturns404) { + Request req; + req.method = "PROPFIND"; + req.path = "/not-dav"; + + auto res = cli_.send(req); + + ASSERT_TRUE(res) << "Error: " << to_string(res.error()); + EXPECT_EQ(StatusCode::NotFound_404, res->status); +} + +TEST_F(ServerTest, CustomRouteUnregisteredMethodIsRejected) { + Request req; + req.method = "UNLOCK"; + req.path = "/dav/dir"; + + cli_.set_keep_alive(true); + auto res = cli_.send(req); + + ASSERT_TRUE(res) << "Error: " << to_string(res.error()); + EXPECT_EQ(StatusCode::BadRequest_400, res->status); + EXPECT_EQ("close", res->get_header_value("Connection")); + EXPECT_FALSE(cli_.is_socket_open()); +} + +TEST_F(ServerTest, CustomRouteDoesNotServeStaticFiles) { + // The mount point serves this path, but only for GET and HEAD. + auto get_res = cli_.Get("/dir/index.html"); + ASSERT_TRUE(get_res) << "Error: " << to_string(get_res.error()); + ASSERT_EQ(StatusCode::OK_200, get_res->status); + + Request req; + req.method = "PROPFIND"; + req.path = "/dir/index.html"; + + auto res = cli_.send(req); + + ASSERT_TRUE(res) << "Error: " << to_string(res.error()); + EXPECT_EQ(StatusCode::NotFound_404, res->status); +} + +TEST_F(ServerTest, CustomRouteKeepAlive) { + const std::string xml = R"()"; + + cli_.set_keep_alive(true); + + for (auto i = 0; i < 2; i++) { + Request req; + req.method = "PROPFIND"; + req.path = "/dav/dir"; + req.body = xml; + + auto res = cli_.send(req); + + ASSERT_TRUE(res) << "Error: " << to_string(res.error()); + EXPECT_EQ(StatusCode::MultiStatus_207, res->status); + EXPECT_EQ(xml, res->body); + EXPECT_TRUE(cli_.is_socket_open()); + } + + // A built-in method must still be served on the same connection. + cli_.set_keep_alive(false); + + auto res = cli_.Get("/hi"); + ASSERT_TRUE(res) << "Error: " << to_string(res.error()); + EXPECT_EQ(StatusCode::OK_200, res->status); + EXPECT_EQ("close", res->get_header_value("Connection")); +} + +TEST_F(ServerTest, CustomRouteExpect100Continue) { + const std::string xml = R"()"; + + Request req; + req.method = "PROPFIND"; + req.path = "/dav/dir"; + req.set_header("Expect", "100-continue"); + req.body = xml; + + auto res = cli_.send(req); + + ASSERT_TRUE(res) << "Error: " << to_string(res.error()); + EXPECT_EQ(StatusCode::MultiStatus_207, res->status); + EXPECT_EQ(xml, res->body); +} + TEST_F(ServerTest, StartTime) { auto res = cli_.Get("/test-start-time"); } #ifdef CPPHTTPLIB_ZLIB_SUPPORT @@ -8964,6 +9179,10 @@ static void test_raw_request(const std::string &req, [&](const Request & /*req*/, Response &res) { res.set_content("ok", "text/plain"); }); + svr.CustomRoute("PROPFIND", "/dav", + [&](const Request & /*req*/, Response &res) { + res.status = StatusCode::MultiStatus_207; + }); // Server read timeout must be longer than the client read timeout for the // bug to reproduce, probably to force the server to process a request @@ -9120,6 +9339,81 @@ TEST(ServerRequestParsingTest, RemoteAddrSetOnBadRequest) { EXPECT_EQ("HTTP/1.1 400 Bad Request", out.substr(0, 24)); } +// A custom method with neither Content-Length nor Transfer-Encoding must be +// answered right away rather than blocking on a read that waits for EOF. +TEST(ServerRequestParsingTest, CustomMethodWithoutFraming) { + std::string out; + test_raw_request("PROPFIND /dav HTTP/1.1\r\nHost: localhost\r\n\r\n", &out); + EXPECT_EQ("HTTP/1.1 207 Multi-Status", out.substr(0, 25)); +} + +TEST(CustomRouteRegistrationTest, RejectsBuiltInMethods) { + const char *methods[] = {"GET", "HEAD", "POST", "PUT", "DELETE", + "CONNECT", "OPTIONS", "TRACE", "PATCH", "PRI"}; + + for (const auto *method : methods) { + Server svr; + svr.CustomRoute(method, "/x", [](const Request &, Response &) {}); + + EXPECT_FALSE(svr.is_valid()) << method; + EXPECT_FALSE(svr.listen(HOST, PORT)) << method; + } +} + +TEST(CustomRouteRegistrationTest, RejectsNonTokenMethods) { + const char *methods[] = {"", "PRO PFIND", "PROP\tFIND", "PROP/FIND", + "PROP,FIND", "PROP:FIND", "PROP(FIND)", "\x01FIND"}; + + for (const auto *method : methods) { + Server svr; + svr.CustomRoute(method, "/x", [](const Request &, Response &) {}); + + EXPECT_FALSE(svr.is_valid()) << method; + } +} + +TEST(CustomRouteRegistrationTest, AcceptsWebDavAndUpnpMethods) { + const char *methods[] = {"PROPFIND", "PROPPATCH", "MKCOL", + "COPY", "MOVE", "LOCK", + "UNLOCK", "REPORT", "SUBSCRIBE"}; + + Server svr; + for (const auto *method : methods) { + svr.CustomRoute(method, "/x", [](const Request &, Response &) {}); + } + + EXPECT_TRUE(svr.is_valid()); +} + +TEST(CustomRouteRegistrationTest, ContentReaderOverloadRejectsBuiltInMethods) { + Server svr; + svr.CustomRoute("POST", "/x", + [](const Request &, Response &, const ContentReader &) {}); + + EXPECT_FALSE(svr.is_valid()); +} + +TEST(CustomRouteRegistrationTest, RejectionIsSticky) { + Server svr; + svr.CustomRoute("PROPFIND", "/a", [](const Request &, Response &) {}); + svr.CustomRoute("GET", "/b", [](const Request &, Response &) {}); + svr.CustomRoute("MKCOL", "/c", [](const Request &, Response &) {}); + + EXPECT_FALSE(svr.is_valid()); +} + +TEST(CustomRouteRegistrationTest, ReportsRejectionToErrorLogger) { + Server svr; + + auto captured = Error::Success; + svr.set_error_logger( + [&](const Error &err, const Request * /*req*/) { captured = err; }); + + svr.CustomRoute("POST", "/x", [](const Request &, Response &) {}); + + EXPECT_EQ(Error::InvalidHTTPMethod, captured); +} + TEST(ServerRequestParsingTest, InvalidFieldValueContains_CR_LF_NUL) { std::string out; std::string request(