diff --git a/docs/design/rpc_handler_result_seam.md b/docs/design/rpc_handler_result_seam.md new file mode 100644 index 0000000000..2b2667a5d5 --- /dev/null +++ b/docs/design/rpc_handler_result_seam.md @@ -0,0 +1,249 @@ +# RPC Writer Seam: "Handler Result" vs "Stored Value" + +## Overview + +The `glz::registry` RPC layer serves two structurally different kinds of values back to a caller, and they must be serialized with different semantics: + +- A **handler result** is whatever a registered callable returns. When that return type is `glz::expected`, the value is a *success/error channel*: a present value is unwrapped and serialized, and an error is translated into the protocol's native error representation (a REPE error header, a JSON-RPC `error` object, or a non-2xx HTTP status). +- A **stored value** is data reflected from a registered object, data member, or merged view. It is serialized *verbatim*. A `glz::expected` here is ordinary data and serializes as its JSON shape (`{"unexpected": ...}` on the error state); it is **not** a failure of the read. + +These two cases share almost all of their machinery, and the only thing distinguishing them is *where the value came from* — a fact known at compile time by which endpoint-registration function was selected. + +This document proposes a single, enforced seam between the two so that the distinction cannot be accidentally collapsed in one protocol but not another. + +### Related issue + +[#2265](https://github.com/stephenberry/glaze/issues/2265) — registry/router under `-fno-exceptions`. During review, the `glz::expected`-as-error-channel logic was found living inside the *shared* `write_response` in REPE and JSON-RPC, which meant reading a registered data member of type `glz::expected` was silently reinterpreted as a protocol error. REST never had the bug because it kept the handler-result path (`write_result`) separate from stored-value serialization (`response::body`). The fix brought REPE and JSON-RPC into line, but nothing *structurally* prevents the asymmetry from returning. This note addresses that. + +### Goals + +1. **One vocabulary across all three protocols** (REPE, JSON-RPC, REST) for "write a handler result" vs "write a stored value", with identical names and a documented contract. +2. **Compile-time enforcement** that every protocol provides both operations — a new protocol cannot ship with only a plain serializer and silently route handler results through it. +3. **A single point of categorization** so the choice "this value is a handler result" vs "this is stored data" is made once, in shared code, rather than re-decided inside each protocol. +4. **Zero runtime overhead** — the distinction is compile-time; no extra branches or allocations in the hot path. See [Performance and compile-time](#performance-and-compile-time) for how each layer preserves this. +5. **Incremental adoption** — each layer is independently valuable and shippable. + +### Non-Goals + +1. **Unifying the output sinks.** REPE writes a binary message buffer (`repe::state_view`), JSON-RPC builds a JSON string (`jsonrpc::state`), REST mutates an HTTP response (`glz::response`). These stay distinct. +2. **Unifying the error vocabularies.** Each protocol's set of acceptable `E` types is intentionally different (REPE: `error_code`/`error_ctx`/string; JSON-RPC: `rpc::error`/`rpc::error_e`/`error_ctx`/string; REST: `http_error`/`error_code`/`error_ctx`/string). The seam must accommodate per-protocol error sets, not flatten them. +3. **A speculative full rewrite** of the per-protocol endpoint-registration bodies. See the layered plan; the heaviest layer is explicitly optional. + +## Background: the two value categories + +The generic registration entry point lives in `include/glaze/rpc/registry.hpp` (`registry::on()` → `register_members`). For each reflected member it decides a *category* and dispatches to a same-named static function on the protocol's `registry_impl` specialization: + +| Category | Generic dispatch target | `glz::expected` return means | +| --- | --- | --- | +| **Handler result** | `register_function_endpoint`, `register_param_function_endpoint`, `register_member_function_endpoint`, `register_member_function_with_params_endpoint` | success/error channel → translate error | +| **Stored value** | `register_endpoint`, `register_object_endpoint`, `register_value_endpoint`, `register_variable_endpoint`, `register_merge_endpoint` | ordinary data → serialize `{"unexpected": ...}` | + +The category is therefore already a compile-time property of *which* `register_*` function the generic layer chose. The seam we want is the guarantee that each of those functions writes its result through the matching path. + +Note that REST is a strict subset of this table: it does not implement `register_merge_endpoint`, so registering a `glz::merge<...>` against a REST registry is a hard compile error (the generic layer calls `impl::register_merge_endpoint` unconditionally at `registry.hpp:303`). The seam itself is unaffected, because it concerns the two *writers* rather than which endpoint kinds a protocol offers, but the asymmetry tempers the "the bodies are ~80% identical" claim in Layer 3b. + +## Current state + +Each protocol independently implements the writers, and the naming has drifted: + +| Protocol | Source | Output sink | Stored-value writer | Handler-result writer | Error translator | +| --- | --- | --- | --- | --- | --- | +| REPE | `rpc/repe/repe.hpp`, `repe_registry_impl.hpp` | `repe::state_view` | `write_response(value, state)` | `write_handler_result(value, state)` | `set_handler_error(state, e)` | +| JSON-RPC | `rpc/jsonrpc_registry_impl.hpp` | `jsonrpc::state` | `write_response(value, state)` | `write_handler_result(value, state)` | `set_handler_error(state, e)` | +| REST | `net/rest_registry_impl.hpp` | `glz::response` | `response::body(value)` *(called inline)* | `write_result(res, result)` | `set_handler_error(res, e)` | + +Observations: + +- REPE and JSON-RPC now agree (`write_response` / `write_handler_result`) after #2265; REST uses a third name (`write_result`) and has **no named stored-value writer** — it calls `res.body(...)` directly at four stored-value GET sites (`rest_registry_impl.hpp` register_endpoint/object/value/variable). The other `res.body(...)` uses live inside `write_result` and `set_handler_error`, which are the handler-result and error paths. +- The three writers do not even share an argument order: REPE/JSON-RPC are value-first (`write_handler_result(value, state)`), REST is sink-first (`write_result(res, result)`). Converging on one vocabulary therefore also means converging on one signature shape (see Layer 1). +- There is no contract a `registry_impl` must satisfy. The mapping from "category" to "writer" is re-established by hand inside each protocol's `register_*_endpoint` bodies. + +## The gap + +The correctness of the seam currently rests entirely on each protocol author calling the right writer inside each registration function. Nothing detects a miswire: + +- Putting the `expected` translation back into a shared `write_response` (the original #2265 bug) compiles and passes most tests, because the data-member case is rare and untested by default. +- A new protocol added tomorrow could implement a single serializer and reuse it for both categories, reproducing the exact asymmetry. +- The inconsistent naming (`write_result` vs `write_handler_result`, and an unnamed stored-value path in REST) makes it harder to grep for "is every handler site using the translating writer?" during review. + +## Proposed design + +### The seam: one vocabulary, two operations + +Standardize on two operations, named identically across all protocols, whose *names encode intent*: + +```cpp +// Stored data: serialize verbatim. A glz::expected is ordinary data. +template void write_stored_value(Sink& sink, V&& value); + +// Handler result: a glz::expected is a success/error channel; an error is +// translated into this protocol's error representation (see set_handler_error). +template void write_handler_result(Sink& sink, V&& value); +``` + +The signatures above are written **sink-first**, matching REST's existing `write_result(response&, R&&)`. That is a deliberate choice the seam must make and apply uniformly, because the protocols disagree today: `write_handler_result` already exists for REPE/JSON-RPC but is *value-first* (`write_handler_result(value, state)`), so adopting the shared signature reorders its parameters. `write_stored_value` is `write_response`/`response::body` given a name. (`Opts` is shown as an explicit template parameter for illustration; in practice each `registry_impl` already carries `Opts` from its enclosing scope.) + +The design is delivered in layers. Layers 1–2 are cheap and capture most of the benefit; Layer 3 is the structural guarantee. + +### Layer 1 — Converge naming and document the contract + +First pick the canonical signature shape and apply it to all three protocols. This note adopts **sink-first** (`(Sink& sink, V&& value)`); REST is already sink-first, REPE and JSON-RPC are not. + +- **REST**: rename `write_result` → `write_handler_result` (already sink-first, so a pure rename). Give the stored-value path a name (`write_stored_value`) wrapping `response::body(...)`, and replace the four inline `res.body(...)` GET sites with it. +- **REPE / JSON-RPC**: rename `write_response` → `write_stored_value` (this touches the value overload, its no-value overload, and every call site), and reorder the handler/stored writers from value-first to sink-first so all three protocols share one signature. +- Add a short doc comment defining the two operations and the `glz::expected` contract at each definition site, cross-referencing this note. + +Cost: mostly mechanical, but **not** a pure rename — REPE/JSON-RPC change argument order, so their call sites move with them. No change to *runtime* behavior. Benefit: a single greppable vocabulary with one signature shape; review can verify at a glance that every handler site uses `write_handler_result` and every data site uses `write_stored_value`. + +### Layer 2 — A `protocol_writer` concept enforced at the boundary + +Define a concept that every `registry_impl` must satisfy, and a per-protocol sink trait alongside the existing `protocol_storage`: + +```cpp +// The sink type a protocol writes responses to. +template struct protocol_sink; // specialized per protocol +template using protocol_sink_t = typename protocol_sink::type; + +// A protocol must expose both halves of the writer vocabulary over its sink. +template +concept protocol_writer = requires(Sink& sink, int probe) { + Impl::template write_stored_value(sink, probe); + Impl::template write_handler_result(sink, probe); +}; +``` + +Assert it where the registry binds a protocol (e.g. in `registry`): + +```cpp +static_assert(protocol_writer, Opts, protocol_sink_t>, + "a registry protocol must provide both write_stored_value and " + "write_handler_result (see docs/design/rpc_handler_result_seam.md)"); +``` + +Cost: low. Benefit: a new protocol that provides only one writer fails to compile with a pointed message. This does not yet prevent *calling the wrong one*, but it guarantees both exist and are named correctly — the precondition for Layer 3. + +Two caveats follow from the recommended order (Layer 2 now, Layer 3a later): + +- **The probe type must track Layer 3a.** The `int probe` works only while `write_handler_result` accepts a plain value. Once Layer 3a makes `write_handler_result` accept *only* `handler_result`, probing it with a bare `int` stops compiling and the concept rejects every protocol. The handler-path probe must then become `handler_result`. Treat that one-line change as part of the Layer 3a PR. +- **The void path is a third operation the vocabulary omits.** Handlers that return `void` (and notifications) produce no body. Today that path is the `write_response(state)` overload in REPE/JSON-RPC and `res.status(204)` in REST; Layer 3a names it `write_empty`. If you want the concept to guarantee the *whole* surface a protocol must provide, add `Impl::write_empty(sink);` to the `requires` block (and to each protocol) at the same time `write_empty` is introduced. + +### Layer 3 — Centralize the categorization (the structural guarantee) + +To make a miswire *impossible* rather than merely *visible*, the choice of writer must be made once, in shared code, rather than inside each protocol's registration functions. + +#### Option 3a — Typed intent wrapper (recommended) + +Introduce a compile-time tag, produced only by the generic layer, that marks a value as a handler result. A bare value is, by definition, stored data. + +```cpp +namespace glz::detail { + // Tags a value as the result of invoking a registered callable. The generic + // registration layer is the single producer; protocol writers dispatch on the type. + template struct handler_result { T value; }; + template handler_result(T&&) -> handler_result>; +} +``` + +Provide a single shared invocation primitive — the *only* sanctioned way to call a registered callable — that always wraps: + +```cpp +// Shared, in the generic layer. Used by every function-endpoint builder. +template +void invoke_to_sink(Sink& sink, F&& f, Args&&... args) +{ + using Impl = registry_impl; + using R = std::invoke_result_t; + // std::invoke (not f(args...)) so the same primitive serves both free callables + // and the member-function endpoints, which dispatch as (value.*func)(...). It + // lowers to a direct call, so there is no runtime cost. + if constexpr (std::is_void_v) { + std::invoke(std::forward(f), std::forward(args)...); + Impl::write_empty(sink); // success, no body + } + else { + // The prvalue result initializes handler_result::value directly (guaranteed + // copy elision), so the return object is never moved or copied into the wrapper. + Impl::template write_handler_result( + sink, detail::handler_result{std::invoke(std::forward(f), std::forward(args)...)}); + } +} +``` + +Then `write_handler_result` accepts `handler_result` (unwrapping `value` before applying the `expected` translation), while `write_stored_value` accepts only bare values. The type system now enforces: + +- A handler result is *always* wrapped (it can only be produced by `invoke_to_sink`), so it cannot reach `write_stored_value`. +- A stored value is never wrapped, so it cannot reach the translating path. + +The wrapper is a compile-time marker only: the return object is reference-forwarded through it, never moved or copied, and the wrapper itself never reaches the serializer. See [Performance and compile-time](#performance-and-compile-time) for the forwarding and instantiation rules that keep this free. + +Two things to settle when this lands: + +- **`write_empty` is introduced here.** It is the named form of REPE/JSON-RPC's `write_response(state)` overload and REST's `res.status(204)`. Adding it to each protocol is part of this step (and, if desired, to the Layer 2 concept; see above). +- **Notifications stay the caller's decision.** `invoke_to_sink` as written always emits — a result or `write_empty`. REPE and JSON-RPC short-circuit on notify / `has_body` *before* producing any body, so `invoke_to_sink` must be called only on the body-expected branch, or `is_notification` must be threaded into it (Layer 3b's policy list already carries it). The "single sanctioned producer" guarantee is about *wrapping* the result, not about owning the notify decision. + +#### Option 3b — Shared endpoint scaffolding (eventual end-state) + +The bodies of `register_function_endpoint`, `register_variable_endpoint`, etc. are ~80% identical across protocols (read params → check notify → invoke or read → write). Factoring the control flow into shared `make_function_endpoint` / `make_variable_endpoint` templates parameterized by a protocol *policy* (the sink type plus `read_params`, `is_notification`, `write_stored_value`, `write_handler_result`, `write_empty`, `set_handler_error`) would leave exactly one copy of the wiring: the function builder calls `write_handler_result`, the variable builder calls `write_stored_value`, and a new protocol supplies only policy primitives. + +This is the strongest guarantee but the largest change: the per-protocol bodies differ in real ways (REPE's `thread_local` param buffers and `has_body()`/`notify()` semantics, JSON-RPC's string assembly, REST's radix-route registration with HTTP method, path spec, and OpenAPI metadata, and REST's endpoint set being a strict subset — it has no merge endpoint at all). Unifying them risks regressions and has a compile-time cost of its own (see [Performance and compile-time](#performance-and-compile-time)), so it should be undertaken only if those bodies are being reworked for other reasons. It is listed here as the target shape, not a near-term task (YAGNI). + +### Recommendation + +Adopt **Layer 1 + Layer 2 now** (cheap, removes the naming asymmetry, and makes "both writers exist" a compile-time invariant), and **Layer 3a** the next time the RPC writers are touched (the typed wrapper makes the seam type-enforced with no runtime cost and a small, local diff). Treat **Layer 3b** as the documented end-state to converge toward opportunistically, not a standalone project. + +## Migration plan + +1. **Layer 1**: pick the sink-first signature shape. Rename REST `write_result` → `write_handler_result`; add `write_stored_value` in REST and replace the four inline `res.body` GET sites. Rename REPE/JSON-RPC `write_response` → `write_stored_value` and reorder their writers to sink-first. Add contract doc comments in all three protocols. No *runtime* behavior change; existing tests must stay green both with and without exceptions. +2. **Layer 2**: add `protocol_sink` specializations and the `protocol_writer` concept; add the `static_assert`. Confirm all three protocols satisfy it. +3. **Layer 3a**: add `detail::handler_result` and `invoke_to_sink`; route the four handler-endpoint builders through it; change each `write_handler_result` to take the wrapper. Each protocol migrates independently behind the shared invocation primitive. + +Each step is a self-contained PR. + +## Testing strategy + +The dual-compilation harness (`tests/networking_tests/registry_no_exceptions_test`, built with and without exceptions) already proves identical behavior across configurations. Generalize the #2265 coverage into a **cross-protocol matrix** so every protocol must demonstrate both rows of the seam: + +| Case | Fixture | Expectation | +| --- | --- | --- | +| Handler returns `expected` in error state | a registered callable | translated to the protocol's error (REPE header `ec`, JSON-RPC `error`, HTTP non-2xx) | +| Data member of type `expected` in error state | a registered data member | serialized as `{"unexpected": ...}` with a *success* status | + +Today REPE and JSON-RPC each have both rows (`repe_expected_data_member_is_serialized_not_intercepted`, `jsonrpc_expected_data_member_is_serialized_not_intercepted`) and REST has the handler row; adding the REST data-member row completes the matrix. Once Layer 3a lands, a new protocol that fails to wire a writer correctly fails to compile, and the matrix catches any remaining semantic gap. + +## Performance and compile-time + +Goal #4 is zero runtime overhead. The hot path is already tight — both categories bottom out in a zero-copy serialize into a reused buffer (`set_body(forward(value))` for REPE/JSON-RPC, `res.body(...)` for REST), and today the prvalue from a handler call is reference-forwarded all the way to the serializer with no moves or allocations. The seam is a compile-time dispatch change, so there are no cycles to reclaim; the work is to *keep* the path free and to avoid paying for the abstraction at compile time. The rules below are what make that hold. + +1. **The wrapper is reference-forwarded, never moved.** Construct `handler_result` from the prvalue result (`handler_result{std::invoke(...)}`) so the return object initializes the member directly under guaranteed copy elision; pass the wrapper by value-from-prvalue (elided) or by `&&`; and forward the inner value out with `*std::move(hr.value)`. The serializer reads it in place. Net moves of the return type: zero, matching the status quo. This matters for large or move-only return types, where an accidental extra move (or worse, a copy) would be a real regression. + +2. **The wrapper never reaches the serializer.** `write_handler_result` unwraps to the bare `V` before calling `glz::write`/`set_body`, so reflection instantiates on `V` exactly as it does today and never on `handler_result`. If the wrapper leaked into the serializer it would be both a hard error (glaze cannot reflect it) and, if "fixed," a duplicate serializer instantiation per wrapped type. Unwrap-first is load-bearing for binary size, not just correctness. + +3. **The sole-producer invariant rules out a hidden copy.** The deduction guide uses `remove_cvref_t`, so an *lvalue* input would copy into the wrapper. Because `invoke_to_sink` is the only producer and always feeds the prvalue `std::invoke(f, args...)`, that copy path is unreachable. The same centralization that makes the seam safe also structurally guarantees the return value is never copied. + +4. **The non-`expected` handler path delegates to the stored-value writer.** Today `write_handler_result` for a non-`expected` type is a straight forward to the verbatim serializer (`repe.hpp:644`), so a handler returning `int` and a stored `int` share one `set_body(int)` instantiation. The renamed seam must preserve this delegation; forking the two writers' serialization would double the `glz::write` instantiations per type for no benefit. This is the main code-size lever, and it is already correct — the note is "do not regress it." + +5. **Compile-time per layer.** Layer 2's concept and `static_assert` cost one `requires` check per protocol binding — negligible. Layer 3a is a small *win*: `invoke_to_sink` collapses the repeated `is_void`/invoke/write dispatch across the four function builders into one primitive per protocol (the heavy serialization was already shared, so the saving is on dispatch logic). Layer 3b is a compile-time *cost*: parameterizing `make_*_endpoint` by a policy struct instantiates those templates per (protocol × endpoint-kind × member type), which can grow instantiation count and build time versus the current flatter inline bodies. In a header-only library that is a first-class concern and a second reason — beyond regression risk — to defer it. + +A property worth banking explicitly: pre-#2265, a stored `expected` data member paid the handler path's `has_value()` branch and dragged in the error-translation instantiation. The fix routes stored values straight to the verbatim serializer, and Layer 3a's type separation makes "stored values pay nothing for the `expected` channel" a structural guarantee rather than a convention a future edit can quietly undo. + +## Risks and trade-offs + +- **Layer 3b is invasive.** Mitigated by making it optional and deferring it until the per-protocol bodies are touched anyway. +- **Layer 3b also costs compile time.** Policy-parameterized endpoint templates instantiate per (protocol × endpoint-kind × member type); see [Performance and compile-time](#performance-and-compile-time). Another reason to defer it in a header-only library. +- **Concept false sense of safety.** Layer 2 proves the writers *exist*, not that they are *called correctly*; that is why Layer 3a (or 3b) is needed for the real guarantee. The layers are complementary, not alternatives. +- **Wrapper ergonomics.** `handler_result` adds a type to read in stack traces and error messages. Kept in `glz::detail` and produced only by `invoke_to_sink`, so it does not leak into user-facing APIs. + +## Appendix: symbol map + +The names and argument orders below are the **current** (pre-Layer-1) state: REPE/JSON-RPC are value-first (`(value, state)`), REST is sink-first (`(res, result)`). Layer 1 renames them to the shared `write_stored_value` / `write_handler_result` vocabulary and converges on a single sink-first order. + +| Concept | REPE | JSON-RPC | REST | +| --- | --- | --- | --- | +| Sink | `repe::state_view` | `jsonrpc::state` | `glz::response` | +| Storage | `protocol_storage` | `protocol_storage` | `protocol_storage` (`http_router`) | +| Impl | `registry_impl` | `registry_impl` | `registry_impl` | +| Stored-value writer | `write_response(value, state)` | `write_response(value, state)` | `response::body(value)` (inline) | +| Empty/null response | `write_response(state)` | `write_response(state)` | `res.status(204)` | +| Handler-result writer | `write_handler_result(value, state)` | `write_handler_result(value, state)` | `write_result(res, result)` | +| Error translator | `set_handler_error(state, e)` | `set_handler_error(state, e)` | `set_handler_error(res, e)` | +| Error vocabulary (`E`) | `error_code`, `error_ctx`, string | `rpc::error`, `rpc::error_e`, `error_ctx`, string | `http_error`, `error_code`, `error_ctx`, string | diff --git a/docs/exceptions.md b/docs/exceptions.md index 9aa146e723..e66c887758 100644 --- a/docs/exceptions.md +++ b/docs/exceptions.md @@ -11,3 +11,64 @@ std::string buffer{}; glz::ex::read_json(obj, buffer); // will throw on a read error ``` +## Building without exceptions + +Glaze compiles cleanly with `-fno-exceptions`. Core read/write functions never throw; they report problems through `glz::error_ctx`/`glz::expected`. The RPC `glz::registry` (REPE, JSON-RPC, and REST) and `glz::http_router` are also usable with `-fno-exceptions`. Errors are surfaced through return values rather than thrown or swallowed. + +### Reporting route registration conflicts + +`http_router::route()` throws `std::runtime_error` on a structural route conflict (a duplicate `:param`/`*wildcard` name at the same position, or a wildcard that is not the final segment) when exceptions are enabled. For exception-free builds, or whenever you want to handle a conflict explicitly, use the non-throwing variants: + +```c++ +glz::http_router router{}; +if (auto ec = router.try_route(glz::http_method::GET, "/users/:id", handler); !ec) { + // ec.error() is a human-readable conflict message +} + +glz::registry registry{}; +if (auto ec = registry.try_on(api); !ec) { + std::fprintf(stderr, "registration failed: %s\n", ec.error().c_str()); +} +``` + +`try_route`/`try_on` return `glz::expected` and never throw; `try_stream` and `try_websocket` give streaming and WebSocket routes the same return-value channel. The router also records the first conflict, queryable via `router.has_route_error()` / `router.route_error()`. + +### Failing a request without throwing + +A registered handler can fail a request by returning `glz::expected`. The registry translates the error into the active protocol's error representation, so the same handler works with or without exceptions: + +```c++ +struct calculator { + // string error -> REPE error_code::parse_error / JSON-RPC internal error (-32603) / REST 500 + std::function(div_params)> divide = [](div_params p) + -> glz::expected { + if (p.denominator == 0) return glz::unexpected(std::string("division by zero")); + return p.numerator / p.denominator; + }; +}; +``` + +An error state must carry a real error: a degenerate success-coded error (`glz::error_code::none`, `glz::rpc::error_e::no_error`, or a `glz::http_error` with a status below 400) is normalized to the protocol's server-error fallback (REPE `parse_error`, JSON-RPC `-32603`, HTTP 500) so a failure can never masquerade as a success on the wire. + +For full JSON-RPC fidelity (custom code, message, and `data`), return `glz::expected` — or its alias `glz::rpc::result`. The `glz::rpc::invalid_params`, `invalid_request`, `method_not_found`, `internal_error`, and `parse_error` helpers (plus `glz::rpc::fail(code, data)` for any other code, including the server-error range) build the `glz::unexpected` for you, with the optional argument carried in the `data` member. REPE handlers may return `glz::expected` to choose the response error code. When exceptions are enabled, a handler that throws is still caught and reported as before. + +#### REST + +For REST the error is translated into an HTTP response rather than an RPC error envelope: the success value is serialized as the response body, while an error sets a non-2xx status and serializes a `glz::http_error` body (`{"status":N,"message":...}`, which a client can read straight back into a `glz::http_error`). The status is derived from the error type: + +- `glz::error_code` maps to a sensible HTTP status (`invalid_body`/`parse_error`/`invalid_query`/`syntax_error`/`constraint_violated` → 400, `method_not_found`/`key_not_found`/`nonexistent_json_ptr` → 404, `timeout` → 504, otherwise 500). +- A `std::string` or `glz::error_ctx` error maps to 500 (treated as a server-side failure). +- Return `glz::expected` to choose the status and message explicitly: + +```c++ +std::function(int)> find_user = [](int id) + -> glz::expected { + if (auto* u = lookup(id)) return *u; + return glz::unexpected(glz::http_error{404, "user not found"}); +}; +``` + +`glz::rpc::error` is JSON-RPC-specific and is **not** a REST error type, since `glaze/net` does not depend on the JSON-RPC extension; use `glz::http_error` for explicit status control. Raw `glz::http_router` handlers that take a `glz::response&` can of course call `res.status(...)` directly instead of returning `glz::expected`. + +> **Behavior change:** Previously a reflected handler returning `glz::expected` had its result serialized directly, so an error came back as a *successful* response whose body was `{"unexpected": }` (a REPE/JSON-RPC result, or an HTTP 200 for REST). The registry now intercepts `glz::expected` as the success/error channel across all three protocols: a present value is still unwrapped and serialized exactly as before, but an error is translated into a protocol error (a REPE/JSON-RPC error response, or a non-2xx HTTP status as described above) instead of a success payload. Only the error path changed. The interception applies only to handler return values: a registered data member of type `glz::expected` is read as stored data and still serializes its `{"unexpected": ...}` JSON shape. A handler that intentionally wants the old payload can likewise return a non-`expected` type that serializes that shape rather than `glz::expected`. + diff --git a/docs/networking/rest-registry.md b/docs/networking/rest-registry.md index 5654cf1a2e..fc317adb44 100644 --- a/docs/networking/rest-registry.md +++ b/docs/networking/rest-registry.md @@ -224,6 +224,20 @@ private: }; ``` +### Non-throwing registration + +`registry.on(service)` throws `std::runtime_error` if registering a service produces a structural route conflict (and is unavailable under `-fno-exceptions`). Use `try_on` to detect registration failures by return value instead of throwing: + +```cpp +glz::registry registry{}; +if (auto ec = registry.try_on(userService); !ec) { + std::fprintf(stderr, "route registration failed: %s\n", ec.error().c_str()); + return; +} +``` + +`try_on` returns `glz::expected` and never throws, so it works identically with and without exceptions. See [Glaze Exceptions](../exceptions.md) for the full no-exceptions story, including failing a request from a handler. + ## Multiple Services ### Service Composition diff --git a/docs/rpc/json-rpc.md b/docs/rpc/json-rpc.md index 6e75a297bc..cec4a47e63 100644 --- a/docs/rpc/json-rpc.md +++ b/docs/rpc/json-rpc.md @@ -57,14 +57,16 @@ int main() { client; // One long living callback per method for the server - server.on<"foo">([](foo_params const& params) -> glz::expected { + server.on<"foo">([](foo_params const& params) -> glz::rpc::result { // access to member variables for the request `foo` // params.foo_a // params.foo_b if( params.foo_a != 0) return foo_result{.foo_c = true, .foo_d = "new world"}; else - // Or return an error: + // Or return an error. For a standard code, glz::rpc::invalid_params("...") and + // friends are shorter; construct rpc::error directly to set a custom message or + // an implementation-defined server-error code as shown here: return glz::unexpected{rpc::error{rpc::error_e::server_error_lower, {}, "my error"}}; }); server.on<"bar">([](bar_params const& params) { @@ -76,7 +78,7 @@ int main() { auto [request_str, inserted] = client.request<"foo">( uuid, foo_params{.foo_a = 1337, .foo_b = "hello world"}, - [](glz::expected value, rpc::id_t id) -> void { + [](rpc::result value, rpc::id_t id) -> void { // Access to value/error and/or id }); // request_str: R"({"jsonrpc":"2.0","method":"foo","params":{"foo_a":1337,"foo_b":"hello world"},"id":"42"})" diff --git a/docs/rpc/jsonrpc-registry.md b/docs/rpc/jsonrpc-registry.md index 0cb0ffd1f9..212d8cded0 100644 --- a/docs/rpc/jsonrpc-registry.md +++ b/docs/rpc/jsonrpc-registry.md @@ -206,6 +206,28 @@ server.call(R"({"jsonrpc":"1.0","method":"greet","id":1})"); // Returns: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request",...},"id":1} ``` +### Failing a request from a handler + +A handler can fail a request by returning `glz::expected`. The error is translated into a JSON-RPC error response, so you do not need to throw (and it works under `-fno-exceptions`): + +```cpp +struct calculator { + std::function(div_params)> divide = [](div_params p) -> glz::rpc::result { + if (p.denominator == 0) { + return glz::rpc::invalid_params("denominator must be non-zero"); + } + return p.numerator / p.denominator; + }; +}; + +server.call(R"({"jsonrpc":"2.0","method":"divide","params":{"numerator":10,"denominator":0},"id":1})"); +// Returns: {"jsonrpc":"2.0","error":{"code":-32602,"message":"Invalid params","data":"denominator must be non-zero"},"id":1} +``` + +`glz::rpc::result` is an alias for `glz::expected`, and the `glz::rpc::invalid_params`, `invalid_request`, `method_not_found`, `internal_error`, and `parse_error` helpers each return a `glz::unexpected` carrying that code; their optional argument becomes the JSON-RPC `data` member while `message` stays the standard text for the code. For any other code (including the implementation-defined server-error range) use `glz::rpc::fail(code, data)`, or construct a `glz::rpc::error` directly for full control over the code, message, and `data`. + +Returning a string error type (for example `glz::expected`) maps to `-32603` (Internal error) with the string as the message, which is convenient when the same handler is reused across protocols. When exceptions are enabled a handler that throws is still caught and reported as `-32603`. + ## ID Types JSON-RPC 2.0 supports string, integer, and null IDs. All are preserved in responses: diff --git a/examples/json-rpc.cpp b/examples/json-rpc.cpp index b82a244bfd..7e942511d8 100644 --- a/examples/json-rpc.cpp +++ b/examples/json-rpc.cpp @@ -35,7 +35,7 @@ int main() rpc::client, rpc::method<"bar", bar_params, bar_result>> client; // One long living callback per method for the server - server.on<"foo">([](const foo_params& params) -> glz::expected { + server.on<"foo">([](const foo_params& params) -> glz::rpc::result { // access to member variables for the request `foo` // params.foo_a // params.foo_b @@ -46,18 +46,20 @@ int main() } else { std::cout << "Server received invalid data:" << params.foo_b << std::endl; - // Or return an error: - return glz::unexpected{glz::rpc::error{glz::rpc::error_e::invalid_params, {}, "foo_a should be equal to 0"}}; + // Or return an error. The invalid_params() helper carries the detail in the + // JSON-RPC `data` member; `message` stays the standard text for the code. + return glz::rpc::invalid_params("foo_a should be equal to 0"); } }); server.on<"bar">([](const bar_params& params) { return bar_result{.bar_c = true, .bar_d = "new world"}; }); std::string uuid{"42"}; - auto client_cb = [](glz::expected value, rpc::id_t id) -> void { + auto client_cb = [](rpc::result value, rpc::id_t id) -> void { if (value) std::cout << "Client received " << value.value().foo_c << ":" << value.value().foo_d << std::endl; else - std::cerr << "Client received error " << value.error().message << std::endl; + std::cerr << "Client received error " << value.error().message + << (value.error().data ? " (" + *value.error().data + ")" : "") << std::endl; }; // One callback per client request @@ -98,7 +100,8 @@ int main() response = server.call(request_str); std::cout << "Server json response :" << response << std::endl; - assert(response == R"({"jsonrpc":"2.0","error":{"code":-32602,"message":"foo_a should be equal to 0"},"id":"42"})"); + assert(response == + R"({"jsonrpc":"2.0","error":{"code":-32602,"message":"Invalid params","data":"foo_a should be equal to 0"},"id":"42"})"); err = client.call(response); std::cout << "Client call with error result :" << err.message << std::endl; } diff --git a/include/glaze/ext/jsonrpc.hpp b/include/glaze/ext/jsonrpc.hpp index 83d1b635f3..087299bb85 100644 --- a/include/glaze/ext/jsonrpc.hpp +++ b/include/glaze/ext/jsonrpc.hpp @@ -99,6 +99,44 @@ namespace glz::rpc }; }; + // Convenience alias for a handler that fails a request with a full-fidelity JSON-RPC + // error. `glz::rpc::result` is just `glz::expected`, so it + // does not have to be spelled twice (once for the stored handler signature and once + // for a lambda's trailing return type). + template + using result = glz::expected; + + // Build a `glz::unexpected` so a handler can `return` it directly from a + // `result` (i.e. `expected`). `data` populates the JSON-RPC `data` + // member (detailed information); `message` stays the standard text for `code`. These + // remove the `glz::unexpected(glz::rpc::error{glz::rpc::error_e::..., ...})` boilerplate + // at handler error sites. + inline glz::unexpected fail(error_e code, std::optional data = std::nullopt) + { + return glz::unexpected(error{code, std::move(data)}); + } + + inline glz::unexpected parse_error(std::optional data = std::nullopt) + { + return fail(error_e::parse_error, std::move(data)); + } + inline glz::unexpected invalid_request(std::optional data = std::nullopt) + { + return fail(error_e::invalid_request, std::move(data)); + } + inline glz::unexpected method_not_found(std::optional data = std::nullopt) + { + return fail(error_e::method_not_found, std::move(data)); + } + inline glz::unexpected invalid_params(std::optional data = std::nullopt) + { + return fail(error_e::invalid_params, std::move(data)); + } + inline glz::unexpected internal_error(std::optional data = std::nullopt) + { + return fail(error_e::internal, std::move(data)); + } + template struct request_t { diff --git a/include/glaze/net/http_router.hpp b/include/glaze/net/http_router.hpp index 7ed82d3d4e..e631614a76 100644 --- a/include/glaze/net/http_router.hpp +++ b/include/glaze/net/http_router.hpp @@ -18,6 +18,7 @@ #include "glaze/json/generic.hpp" #include "glaze/net/http.hpp" #include "glaze/net/url.hpp" +#include "glaze/util/expected.hpp" #include "glaze/util/key_transformers.hpp" namespace glz @@ -46,6 +47,18 @@ namespace glz std::string_view message{}; }; + // Returned (as the error of a glz::expected) by a reflected REST handler to fail + // the request with a chosen HTTP status. The registry sets the response status to + // `status` and serializes this object as the body, so a client can deserialize the + // response straight back into a glz::http_error. Handlers that don't need a specific + // status can return glz::expected with a glz::error_code or string error instead; the + // registry maps those to a status automatically (see rest_registry_impl.hpp). + struct http_error + { + int status{500}; + std::string message{}; + }; + // Response builder struct response { @@ -205,11 +218,23 @@ namespace glz std::vector tags{}; std::unordered_map constraints{}; + // HTTP status of a successful response. 200 when the handler returns a body, 204 + // (No Content) for void/expected handlers and update (PUT) endpoints. + int success_status{200}; + // Type information for schema generation std::optional request_body_schema{}; std::optional response_schema{}; std::optional request_body_type_name{}; std::optional response_type_name{}; + + // Error response, populated when a reflected handler returns glz::expected. The + // error body is a glz::http_error; error_status_key is the OpenAPI response key: + // "500" when the status is fixed (string/error_ctx errors), otherwise "default" + // because the status depends on the error value (error_code or glz::http_error). + std::optional error_response_schema{}; + std::optional error_response_type_name{}; + std::string error_status_key{"default"}; }; /** @@ -323,26 +348,28 @@ namespace glz * @param path The route path, may contain ":param" or trailing "*name" wildcards. * @param handle The handler to associate with the route. * @param spec Optional spec for the route. + * @return Empty on success; an error message describing the structural + * conflict on failure. Never throws and never aborts, so the same + * code path is used with and without exceptions. */ - void add(http_method method, std::string_view path, H handle, const route_spec& spec = {}) + [[nodiscard]] expected add(http_method method, std::string_view path, H handle, + const route_spec& spec = {}) { - try { - // Install in the radix tree first. add_route can throw on conflict - // (duplicate :param or *wildcard names at the same position); if it - // throws we want the routes map to stay in sync with the tree, not - // to silently retain an entry that iteration sees but match() never - // reaches. Pass handle by value so we still own a copy to move into - // the routes entry below. - add_route(method, path, handle, spec.constraints); - - auto& entry = routes[std::string(path)][method]; - entry.handle = std::move(handle); - entry.spec = spec; - } - catch (const std::exception& e) { - std::fprintf(stderr, "Error adding route '%.*s': %s\n", static_cast(path.length()), path.data(), - e.what()); + // Install in the radix tree first. add_route reports structural conflicts + // (duplicate :param or *wildcard names at the same position, or a wildcard + // that is not the final segment) by returning an error rather than throwing. + // On failure we leave the routes map untouched so it stays in sync with the + // tree, instead of silently retaining an entry that iteration sees but match() + // never reaches. Pass handle by value so we still own a copy to move into the + // routes entry below. + if (auto result = add_route(method, path, handle, spec.constraints); !result) { + return result; } + + auto& entry = routes[std::string(path)][method]; + entry.handle = std::move(handle); + entry.spec = spec; + return {}; } /** @@ -389,14 +416,15 @@ namespace glz mutable radix_node root; std::unordered_map> direct_routes; - void add_route(http_method method, std::string_view path, H handle, - const std::unordered_map& constraints = {}) + [[nodiscard]] expected add_route( + http_method method, std::string_view path, H handle, + const std::unordered_map& constraints = {}) { std::string path_str(path); if (path_str.find(':') == std::string::npos && path_str.find('*') == std::string::npos) { direct_routes[path_str][method] = handle; - return; + return {}; } std::vector segments = split_path(path); @@ -419,8 +447,8 @@ namespace glz current->parameter_child->full_path = current->full_path + "/" + segment; } else if (current->parameter_child->parameter_name != param_name) { - throw std::runtime_error("Route conflict: different parameter names at same position: :" + - current->parameter_child->parameter_name + " vs :" + param_name); + return glz::unexpected("Route conflict: different parameter names at same position: :" + + current->parameter_child->parameter_name + " vs :" + param_name); } current = current->parameter_child.get(); @@ -429,7 +457,7 @@ namespace glz std::string wildcard_name = segment.substr(1); if (i != segments.size() - 1) { - throw std::runtime_error("Wildcard must be the last segment in route: " + path_str); + return glz::unexpected("Wildcard must be the last segment in route: " + path_str); } if (!current->wildcard_child) { @@ -440,8 +468,8 @@ namespace glz current->wildcard_child->full_path = current->full_path + "/" + segment; } else if (current->wildcard_child->parameter_name != wildcard_name) { - throw std::runtime_error("Route conflict: different wildcard names at same position: *" + - current->wildcard_child->parameter_name + " vs *" + wildcard_name); + return glz::unexpected("Route conflict: different wildcard names at same position: *" + + current->wildcard_child->parameter_name + " vs *" + wildcard_name); } current = current->wildcard_child.get(); @@ -464,6 +492,8 @@ namespace glz if (!constraints.empty()) { current->constraints[method] = constraints; } + + return {}; } bool match_node(radix_node* node, const std::vector& segments, size_t index, http_method method, @@ -780,6 +810,11 @@ namespace glz * * Note: http_server::mount() only accepts the default http_router (basic_http_router<>). * Custom handler routers are intended for standalone use or with custom server implementations. + * + * Thread-safety: route registration (route()/stream()/websocket()/try_* and the + * route_error() side-channel) mutates the router without synchronization and is not + * thread-safe. Register all routes during startup, before the router begins serving; + * once serving, match()/match_websocket() are const and safe for concurrent reads. */ template > requires std::invocable @@ -826,14 +861,57 @@ namespace glz * @param handle The handler function to call when this route matches * @param spec Optional spec for the route. * @return Reference to this router for method chaining - * @throws std::runtime_error if there's a route conflict + * @throws std::runtime_error on a structural route conflict when exceptions + * are enabled. Under -fno-exceptions the conflict is recorded instead + * (see route_error()); use try_route() for explicit handling in either + * configuration. A structural conflict means duplicate ":param" or + * "*wildcard" names at the same position, or a wildcard that is not the + * final segment; re-registering an identical route simply overwrites it. */ basic_http_router& route(http_method method, std::string_view path, handler handle, const route_spec& spec = {}) { - normal_routes.add(method, path, std::move(handle), spec); + surface_route_result(normal_routes.add(method, path, std::move(handle), spec)); return *this; } + /** + * @brief Register a route without throwing, reporting conflicts by return value. + * + * Behaves identically with and without exceptions: a structural route conflict + * is returned as an error message rather than thrown or logged to stderr. This is + * the recommended registration entry point for exception-free builds. As the + * explicit return-value path, try_route() does not touch the route_error() side- + * channel (which exists for the chaining route()/stream()/websocket() variants); + * the caller owns the returned error. + * + * @return Empty on success, or the conflict message on failure. + */ + [[nodiscard]] expected try_route(http_method method, std::string_view path, handler handle, + const route_spec& spec = {}) + { + return normal_routes.add(method, path, std::move(handle), spec); + } + + /** + * @brief Whether a route registration error has been recorded. + * + * Populated under -fno-exceptions, where route()/stream()/websocket() record the + * first conflict instead of throwing (they return *this for chaining and so cannot + * report it by value). The try_* variants report conflicts purely by return value + * and do not write here. Use clear_route_error() to reset. + */ + [[nodiscard]] bool has_route_error() const noexcept { return !route_error_.empty(); } + + /** + * @brief The first recorded route registration error message (empty if none). + */ + [[nodiscard]] std::string_view route_error() const noexcept { return route_error_; } + + /** + * @brief Clear any recorded route registration error. + */ + void clear_route_error() noexcept { route_error_.clear(); } + /** * @brief Register a GET route */ @@ -940,14 +1018,30 @@ namespace glz * lifecycle and write chunked responses through streaming_response. * * Supports the same path-parameter syntax as normal routes (":param" and "*name"). + * Like route(), a structural conflict throws when exceptions are enabled and is + * recorded (see route_error()) otherwise; use try_stream() to handle conflicts by + * return value in either configuration. */ basic_http_router& stream(http_method method, std::string_view path, streaming_handler handle, const route_spec& spec = {}) { - streaming_routes.add(method, path, std::move(handle), spec); + surface_route_result(streaming_routes.add(method, path, std::move(handle), spec)); return *this; } + /** + * @brief Register a streaming route without throwing, reporting conflicts by + * return value. The streaming counterpart of try_route(); like it, the error is + * returned, not written to the route_error() side-channel. + * + * @return Empty on success, or the conflict message on failure. + */ + [[nodiscard]] expected try_stream(http_method method, std::string_view path, + streaming_handler handle, const route_spec& spec = {}) + { + return streaming_routes.add(method, path, std::move(handle), spec); + } + /** * @brief Register a streaming GET route. */ @@ -970,13 +1064,30 @@ namespace glz * Supports the same path-parameter syntax as normal routes. The HTTP server * detects the upgrade handshake (Upgrade: websocket) and dispatches to the * matching server, populating request.params from the path. + * + * Like route(), a structural conflict throws when exceptions are enabled and is + * recorded (see route_error()) otherwise; use try_websocket() to handle conflicts + * by return value in either configuration. */ basic_http_router& websocket(std::string_view path, websocket_handler server, const route_spec& spec = {}) { - websocket_routes.add(http_method::GET, path, std::move(server), spec); + surface_route_result(websocket_routes.add(http_method::GET, path, std::move(server), spec)); return *this; } + /** + * @brief Register a WebSocket handler without throwing, reporting conflicts by + * return value. The WebSocket counterpart of try_route(); like it, the error is + * returned, not written to the route_error() side-channel. + * + * @return Empty on success, or the conflict message on failure. + */ + [[nodiscard]] expected try_websocket(std::string_view path, websocket_handler server, + const route_spec& spec = {}) + { + return websocket_routes.add(http_method::GET, path, std::move(server), spec); + } + /** * @brief Register middleware to be executed before route handlers * @@ -1075,6 +1186,32 @@ namespace glz * @brief Vector of middleware handlers */ std::vector middlewares; + + private: + /** + * @brief Surface a route-table registration result. + * + * Where exceptions are enabled a structural conflict is thrown, restoring the + * documented route() contract and making misuse impossible to miss. Under + * -fno-exceptions the first conflict is recorded for query via route_error(). + * The error is never swallowed to stderr and never aborts the process, so both + * configurations report failures rather than hiding them. + */ + void surface_route_result(expected result) + { + if (result) { + return; + } +#if __cpp_exceptions + throw std::runtime_error(std::move(result.error())); +#else + if (route_error_.empty()) { + route_error_ = std::move(result.error()); + } +#endif + } + + std::string route_error_{}; }; /** diff --git a/include/glaze/net/http_server.hpp b/include/glaze/net/http_server.hpp index 3b36f91e98..9e42f7f32c 100644 --- a/include/glaze/net/http_server.hpp +++ b/include/glaze/net/http_server.hpp @@ -1100,7 +1100,11 @@ namespace glz op.tags = route_entry.spec.tags; } op.operationId = std::string(to_string(method)) + route_path; - op.responses["200"].description = "OK"; + + // Success response keyed by the handler's actual status (204 for void / + // update endpoints, 200 when a body is returned). + const std::string success_key = std::to_string(route_entry.spec.success_status); + op.responses[success_key].description = route_entry.spec.success_status == 204 ? "No Content" : "OK"; // Add request body schema if (route_entry.spec.request_body_schema) { @@ -1127,7 +1131,7 @@ namespace glz if (auto schema_val = glz::read_json(*route_entry.spec.response_schema)) { res_obj.content.value()["application/json"].schema = *schema_val; } - op.responses["200"] = res_obj; + op.responses[success_key] = res_obj; // Add schema to components if (!spec.components) spec.components.emplace(); @@ -1137,6 +1141,27 @@ namespace glz } } + // Add error response (handlers that fail by returning glz::expected) + if (route_entry.spec.error_response_schema) { + openapi_response err_obj; + err_obj.description = "Error response"; + err_obj.content.emplace(); + if (auto schema_val = glz::read_json(*route_entry.spec.error_response_schema)) { + err_obj.content.value()["application/json"].schema = *schema_val; + } + op.responses[route_entry.spec.error_status_key] = err_obj; + + // Add error schema to components + if (route_entry.spec.error_response_type_name) { + if (!spec.components) spec.components.emplace(); + if (!spec.components->schemas) spec.components->schemas.emplace(); + if (auto schema_val = glz::read_json(*route_entry.spec.error_response_schema)) { + spec.components->schemas->operator[](*route_entry.spec.error_response_type_name) = + *schema_val; + } + } + } + // Extract path parameters auto segments = http_router::split_path(route_path); for (const auto& segment : segments) { diff --git a/include/glaze/net/rest_registry_impl.hpp b/include/glaze/net/rest_registry_impl.hpp index 3fd8929a83..baab3127d3 100644 --- a/include/glaze/net/rest_registry_impl.hpp +++ b/include/glaze/net/rest_registry_impl.hpp @@ -21,12 +21,123 @@ namespace glz using type = http_router; }; + namespace detail + { + // Maps a reflected handler's return type to the type that actually appears in a + // successful REST response body: glz::expected unwraps to T so the generated + // response schema describes the value rather than the expected wrapper. + template + struct rest_success_type + { + using type = R; + }; + template + struct rest_success_type> + { + using type = T; + }; + template + using rest_success_type_t = typename rest_success_type>::type; + } + // Implementation for REST protocol template struct registry_impl { using enum http_method; + // --- glz::expected -> HTTP translation for reflected handlers -------------------- + // A reflected handler may signal failure by returning glz::expected instead of + // throwing (required under -fno-exceptions, convenient otherwise). These helpers are + // the REST peer of repe::set_handler_error / jsonrpc::set_handler_error: the success + // value is serialized as the body, while the error is mapped to an HTTP status and a + // glz::http_error body. Note: rpc::error/error_e (full JSON-RPC fidelity) are not + // accepted here, since glaze/net must not depend on the glaze/ext JSON-RPC types; + // use glz::http_error for explicit status control. + + // Map a Glaze error_code to the most appropriate HTTP status. + static int http_status_for(error_code ec) + { + switch (ec) { + case error_code::invalid_body: + case error_code::parse_error: + case error_code::invalid_query: + case error_code::syntax_error: + case error_code::constraint_violated: + return 400; + case error_code::method_not_found: + case error_code::key_not_found: + case error_code::nonexistent_json_ptr: + return 404; + case error_code::timeout: + return 504; + default: + return 500; + } + } + + // Translate a handler's glz::expected error into an HTTP status + body. Supported + // error types: + // - glz::http_error -> its explicit status and message + // - glz::error_code -> http_status_for(ec) with the code's name + // - glz::error_ctx -> 500 with the formatted context + // - string-like -> 500 with the message + // A degenerate glz::http_error whose status is not an error status (< 400, which + // would make the failure look like a success or redirect on the wire) is normalized + // to 500; the message is preserved. The same guard covers error_code::none, which + // http_status_for already maps to 500. + template + static void set_handler_error(response& res, E&& error) + { + using DE = std::remove_cvref_t; + if constexpr (std::same_as) { + if (error.status < 400) [[unlikely]] { + res.status(500).template body(http_error{500, std::forward(error).message}); + } + else { + res.status(error.status).template body(std::forward(error)); + } + } + else if constexpr (std::same_as) { + const int s = http_status_for(error); + res.status(s).template body(http_error{s, format_error(error)}); + } + else if constexpr (std::same_as) { + res.status(500).template body(http_error{500, format_error(error)}); + } + else { + static_assert(std::convertible_to, + "A REST handler's glz::expected error type must be glz::http_error, glz::error_code, " + "glz::error_ctx, or convertible to std::string_view."); + res.status(500).template body(http_error{500, std::string(std::string_view(error))}); + } + } + + // Serialize a (possibly glz::expected) handler result into the response. On a + // glz::expected, the success value is serialized (204 for an expected); + // the error is translated by set_handler_error. Plain results serialize as before. + template + static void write_result(response& res, R&& result) + { + using V = std::remove_cvref_t; + if constexpr (is_expected) { + if (result.has_value()) { + if constexpr (std::is_void_v) { + res.status(204); // No Content + } + else { + res.template body(*std::forward(result)); + } + } + else { + set_handler_error(res, std::forward(result).error()); + } + } + else { + res.template body(std::forward(result)); + } + } + // Helper method to convert a JSON pointer path to a REST path static std::string convert_to_rest_path(sv json_pointer_path) { @@ -85,6 +196,11 @@ namespace glz spec.description = description; spec.tags = tags; + // A void response means the handler replies 204 No Content (update endpoints, + // void functions, and expected handlers, which all pass ResponseType = + // void here); a non-void response is serialized with a 200 body. + spec.success_status = std::same_as ? 204 : 200; + if constexpr (!std::same_as) { spec.request_body_schema = generate_schema_for_type(); spec.request_body_type_name = get_type_name(); @@ -98,6 +214,28 @@ namespace glz return spec; } + // Record the error response for a reflected handler whose return type is the + // (possibly glz::expected) Result. set_handler_error always serializes a + // glz::http_error body, so that is the documented error schema; the response key + // is fixed at "500" for string/error_ctx errors and "default" otherwise, since an + // error_code maps to several statuses and a glz::http_error carries any status. + template + static void describe_error_response(route_spec& spec) + { + using R = std::remove_cvref_t; + if constexpr (is_expected) { + using E = typename R::error_type; + spec.error_response_schema = generate_schema_for_type(); + spec.error_response_type_name = "http_error"; + if constexpr (std::same_as || std::convertible_to) { + spec.error_status_key = "500"; + } + else { + spec.error_status_key = "default"; + } + } + } + template static void register_endpoint(const sv path, T& value, RegistryType& reg) { @@ -131,7 +269,10 @@ namespace glz { std::string rest_path = convert_to_rest_path(path); - auto get_spec = create_route_spec_with_types("Get " + get_type_name(), {"data"}); + using ResponseType = detail::rest_success_type_t; + auto get_spec = + create_route_spec_with_types("Get " + get_type_name(), {"data"}); + describe_error_response(get_spec); // GET handler for functions reg.endpoints.route( GET, rest_path, @@ -141,8 +282,7 @@ namespace glz res.status(204); // No Content } else { - auto result = func(); - res.body(result); + write_result(res, func()); } }, get_spec); @@ -154,7 +294,10 @@ namespace glz std::string rest_path = convert_to_rest_path(path); using Result = std::invoke_result_t; - auto post_spec = create_route_spec_with_types("Create " + get_type_name(), {"data"}); + using ResponseType = detail::rest_success_type_t; + auto post_spec = + create_route_spec_with_types("Create " + get_type_name(), {"data"}); + describe_error_response(post_spec); // POST handler for functions with parameters reg.endpoints.route( POST, rest_path, @@ -172,8 +315,7 @@ namespace glz res.status(204); // No Content } else { - auto result = func(std::move(params_result)); - res.body(result); + write_result(res, func(std::move(params_result))); } }, post_spec); @@ -265,7 +407,10 @@ namespace glz { std::string rest_path = convert_to_rest_path(path); - auto get_spec = create_route_spec_with_types("Get " + get_type_name(), {"data"}); + using ResponseType = detail::rest_success_type_t; + auto get_spec = + create_route_spec_with_types("Get " + get_type_name(), {"data"}); + describe_error_response(get_spec); // GET handler for member functions with no args reg.endpoints.route( GET, rest_path, @@ -275,8 +420,7 @@ namespace glz res.status(204); // No Content } else { - auto result = (value.*func)(); - res.body(result); + write_result(res, (value.*func)()); } }, get_spec); @@ -287,7 +431,10 @@ namespace glz { std::string rest_path = convert_to_rest_path(path); - auto post_spec = create_route_spec_with_types("Create " + get_type_name(), {"data"}); + using ResponseType = detail::rest_success_type_t; + auto post_spec = + create_route_spec_with_types("Create " + get_type_name(), {"data"}); + describe_error_response(post_spec); // POST handler for member functions with args reg.endpoints.route( POST, rest_path, @@ -305,8 +452,7 @@ namespace glz res.status(204); // No Content } else { - auto result = (value.*func)(std::move(params_result)); - res.body(result); + write_result(res, (value.*func)(std::move(params_result))); } }, post_spec); diff --git a/include/glaze/rpc/jsonrpc_registry_impl.hpp b/include/glaze/rpc/jsonrpc_registry_impl.hpp index 6b6a08ef66..f96f7a96c4 100644 --- a/include/glaze/rpc/jsonrpc_registry_impl.hpp +++ b/include/glaze/rpc/jsonrpc_registry_impl.hpp @@ -6,6 +6,7 @@ #include "glaze/core/opts.hpp" #include "glaze/ext/jsonrpc.hpp" #include "glaze/glaze.hpp" +#include "glaze/util/expected.hpp" namespace glz { @@ -31,7 +32,14 @@ namespace glz template concept is_state = std::same_as, state>; - // Write a successful JSON RPC response + // forward declaration: translate a glz::expected handler error into a JSON-RPC error + template + void set_handler_error(is_state auto&& state, E&& error); + + // Write a successful JSON RPC response. The value is serialized as-is; a + // glz::expected is written through its normal JSON serializer, so an error state + // becomes {"unexpected": ...}. Handler results that should treat a glz::expected + // error as a request failure go through write_handler_result instead. template void write_response(Value&& value, is_state auto&& state) { @@ -57,6 +65,36 @@ namespace glz state.response += "}"; } + // Write a registered handler's result. Unlike write_response, a glz::expected + // return is interpreted as a success/error channel: the success value is serialized + // and an error is translated into a JSON-RPC error response (see set_handler_error), + // so a handler can signal failure by returning glz::unexpected(...) rather than + // throwing (required under -fno-exceptions, convenient otherwise). Applied only at + // the function/member-function call sites; a registered data member of type + // glz::expected is read through write_response and still serializes its JSON shape. + // See #2265. + template + void write_handler_result(Value&& value, is_state auto&& state) + { + using V = std::remove_cvref_t; + if constexpr (glz::is_expected) { + if (value.has_value()) { + if constexpr (std::is_void_v) { + write_response(state); + } + else { + write_response(*std::forward(value), state); + } + } + else { + set_handler_error(state, std::forward(value).error()); + } + } + else { + write_response(std::forward(value), state); + } + } + // Write a successful JSON RPC response with null result template void write_response(is_state auto&& state) @@ -91,6 +129,61 @@ namespace glz state.response += "}"; } + // Translate the error of a glz::expected handler result into a JSON-RPC error + // response. Supported error types: + // - glz::rpc::error -> its code, message, and optional data (full fidelity) + // - glz::rpc::error_e -> that code with its standard message + // - glz::error_ctx -> error_e::internal, "Internal error", context message in data + // - string-like -> error_e::internal, "Internal error", the string in data + // error_e::internal (-32603) is the JSON-RPC code reserved for server-side errors. + // The handler supplies no code in the error_ctx/string cases, so the reserved code's + // standard message is kept in "message" and the handler's detail is carried in "data" + // (matching the thrown path in registry.hpp). An empty detail emits no data member. + // A degenerate error carrying the success code (error_e::no_error, which would emit + // a malformed JSON-RPC error with "code":0) is normalized to error_e::internal so an + // error state always produces a well-formed error response; a custom message and + // data are preserved. + template + void set_handler_error(is_state auto&& state, E&& error) + { + using DE = std::remove_cvref_t; + if constexpr (std::same_as) { + if (error.code == rpc::error_e::no_error) [[unlikely]] { + // Replace a default-constructed "No error" message along with the code; + // keep a deliberately customized one. + const bool default_message = error.message == rpc::code_as_sv(rpc::error_e::no_error); + write_error(state, rpc::error_e::internal, + default_message ? std::string(rpc::code_as_sv(rpc::error_e::internal)) : error.message, + error.data); + } + else { + write_error(state, error.code, error.message, error.data); + } + } + else if constexpr (std::same_as) { + if (error == rpc::error_e::no_error) [[unlikely]] { + write_error(state, rpc::error_e::internal, std::string(rpc::code_as_sv(rpc::error_e::internal)), + "handler returned an error state with error_e::no_error"); + } + else { + write_error(state, error, std::string(rpc::code_as_sv(error))); + } + } + else if constexpr (std::same_as) { + const std::string_view detail = error.custom_error_message; + write_error(state, rpc::error_e::internal, "Internal error", + detail.empty() ? std::nullopt : std::optional(detail)); + } + else { + static_assert(std::convertible_to, + "A JSON-RPC handler's glz::expected error type must be glz::rpc::error, glz::rpc::error_e, " + "glz::error_ctx, or convertible to std::string_view."); + const std::string_view detail = std::string_view(error); + write_error(state, rpc::error_e::internal, "Internal error", + detail.empty() ? std::nullopt : std::optional(detail)); + } + } + // Read params from JSON RPC request template bool read_params(Value&& value, is_state auto&& state) @@ -170,7 +263,7 @@ namespace glz std::ignore = func(); return; } - jsonrpc::write_response(func(), state); + jsonrpc::write_handler_result(func(), state); }; } } @@ -202,7 +295,7 @@ namespace glz } else { auto ret = func(params); - jsonrpc::write_response(ret, state); + jsonrpc::write_handler_result(ret, state); } }; } @@ -261,7 +354,7 @@ namespace glz return; } - jsonrpc::write_response((value.*func)(), state); + jsonrpc::write_handler_result((value.*func)(), state); } }; } @@ -293,7 +386,7 @@ namespace glz return; } - jsonrpc::write_response((value.*func)(input), state); + jsonrpc::write_handler_result((value.*func)(input), state); } }; } diff --git a/include/glaze/rpc/registry.hpp b/include/glaze/rpc/registry.hpp index c84f23c617..9ad7fb442e 100644 --- a/include/glaze/rpc/registry.hpp +++ b/include/glaze/rpc/registry.hpp @@ -6,6 +6,7 @@ #include "glaze/glaze.hpp" #include "glaze/rpc/repe/buffer.hpp" #include "glaze/rpc/repe/repe.hpp" +#include "glaze/util/expected.hpp" namespace glz { @@ -252,9 +253,58 @@ namespace glz register_members(value); } - // Register multiple C++ types merged together, allowing the root endpoint to return a combined view + // Non-throwing registration: same as on(), but reports a route-registration + // conflict (REST protocol) by return value instead of throwing. The returned + // error is identical with and without exceptions (the first conflict), so this is + // the recommended entry point for exception-free builds. The resulting route table + // can differ on conflict, however: under exceptions the first conflict aborts the + // remaining registration, whereas under -fno-exceptions it is recorded and the + // later routes still register. This is unreachable through reflected registration, + // which emits only literal paths (direct routes never structurally conflict); it + // surfaces only if conflicting parameterized routes are added manually. For + // REPE/JSON-RPC, registration cannot fail and this always succeeds. See #2265. + // + // Note: for REST this clears the router's route_error() side-channel first, so a + // conflict recorded by this registration is attributable to it. Any conflict a + // prior chaining route()/stream()/websocket() call recorded under -fno-exceptions + // is discarded by that reset -- check route_error() before calling try_on if you + // mix the two registration styles. + template + requires(glaze_object_t || reflectable) + [[nodiscard]] expected try_on(T& value) + { + if constexpr (Proto == REST) { + endpoints.clear_route_error(); +#if __cpp_exceptions + try { + on(value); + } + catch (const std::exception& e) { + return glz::unexpected(std::string(e.what())); + } + catch (...) { + return glz::unexpected(std::string("Unknown route registration error")); + } +#else + on(value); +#endif + if (endpoints.has_route_error()) { + return glz::unexpected(std::string(endpoints.route_error())); + } + } + else { + on(value); + } + return {}; + } + + // Register multiple C++ types merged together, allowing the root endpoint to return a combined view. + // Not available for REST: merge registration relies on register_merge_endpoint, which the REST + // protocol does not provide (a merged combined-view root endpoint is not modeled for REST). The + // requires-clause makes that an honest "no matching function" diagnostic instead of a hard error + // deep inside the implementation. template - requires(sizeof...(Ts) > 0 && (... && (glaze_object_t || reflectable))) + requires(sizeof...(Ts) > 0 && (... && (glaze_object_t || reflectable)) && Proto != REST) void on(glz::merge& merged) { using impl = registry_impl; @@ -270,6 +320,18 @@ namespace glz }); } + // Non-throwing registration for merged objects (see try_on(T&) above). Like on(merge) this is + // constrained to non-REST protocols, so it never advertises REST merge support that does not + // exist. For REPE/JSON-RPC registration cannot fail, so this simply registers and succeeds; the + // route-error handling that try_on(T&) needs for REST is therefore unnecessary here. + template + requires(sizeof...(Ts) > 0 && (... && (glaze_object_t || reflectable)) && Proto != REST) + [[nodiscard]] expected try_on(glz::merge& merged) + { + on(merged); + return {}; + } + /// Message-based call for REPE protocol (deprecated) /// @deprecated Use the zero-copy span-based overload instead: /// `call(std::span, std::string&)` @@ -328,6 +390,12 @@ namespace glz resp.reset(req_view); repe::state_view state{req_view, resp}; + // Glaze's own (de)serialization reports failures through error codes, not + // exceptions, so this catch only guards exceptions escaping user handler + // code. Under -fno-exceptions there is nothing to catch: handlers instead + // signal failure by returning glz::expected, which write_response() + // translates into a REPE error. See #2265. +#if __cpp_exceptions try { it->second(state); } @@ -335,6 +403,9 @@ namespace glz resp.reset(req_view); resp.set_error(error_code::parse_error, detail::build_registry_error(in.query, e.what())); } +#else + it->second(state); +#endif } } else { @@ -412,6 +483,10 @@ namespace glz // Zero-copy call: state_view references the parsed request and response builder directly repe::state_view state{req, resp}; + // The catch only guards exceptions from user handler code; glaze's own + // (de)serialization uses error codes. Under -fno-exceptions handlers report + // failure by returning glz::expected (translated by write_response). See #2265. +#if __cpp_exceptions try { it->second(state); } @@ -425,6 +500,9 @@ namespace glz resp.set_error(error_code::parse_error, "Unknown error"); return; } +#else + it->second(state); +#endif // For notifications, response buffer stays empty (no response sent) } @@ -529,6 +607,10 @@ namespace glz bool has_params = !req.params.str.empty() && req.params.str != "null"; jsonrpc::state state{req.id, response, is_notification, has_params, req.params.str}; + // The catch only guards exceptions from user handler code; glaze's own + // (de)serialization uses error codes. Under -fno-exceptions handlers report + // failure by returning glz::expected (translated by write_response). See #2265. +#if __cpp_exceptions try { it->second(std::move(state)); } @@ -540,6 +622,9 @@ namespace glz return R"({"jsonrpc":"2.0","error":{"code":-32603,"message":"Internal error","data":)" + write_json(std::string_view{e.what()}).value_or("null") + R"(},"id":)" + id_json + "}"; } +#else + it->second(std::move(state)); +#endif if (is_notification) { return std::nullopt; diff --git a/include/glaze/rpc/repe/repe.hpp b/include/glaze/rpc/repe/repe.hpp index 3fe78e816a..3bb07bc554 100644 --- a/include/glaze/rpc/repe/repe.hpp +++ b/include/glaze/rpc/repe/repe.hpp @@ -9,6 +9,7 @@ #include "glaze/core/opts.hpp" #include "glaze/json/write.hpp" #include "glaze/rpc/repe/header.hpp" +#include "glaze/util/expected.hpp" namespace glz { @@ -563,25 +564,97 @@ namespace glz::repe return size_t(b - start); } - /// Write response with a value (zero-copy to output buffer) - template - void write_response(Value&& value, state_view& state) + /// Write response without a value (null body) + template + void write_response(state_view& state) { state.out.reset(state.in); - auto ec = state.out.template set_body(std::forward(value)); + auto ec = state.out.template set_body(nullptr); if (bool(ec)) [[unlikely]] { state.out.set_error(ec, "Failed to serialize response"); } } - /// Write response without a value (null body) - template - void write_response(state_view& state) + /// Translate the error of a glz::expected handler result into a REPE error + /// response, so a registered handler can fail a request without throwing. + /// Supported error types: + /// - glz::error_code -> that code with an empty message + /// - glz::error_ctx -> its code and message + /// - string-like -> error_code::parse_error + the message. This is the same + /// error code a thrown handler is mapped to when caught (see + /// registry call()), so the two paths agree on the code. The + /// body differs, though: the thrown path wraps the message via + /// build_registry_error(query, ...) for context, while this path + /// uses the returned string verbatim. + /// A degenerate error_code::none (which would make the failure indistinguishable from + /// success on the wire) is normalized to error_code::parse_error -- the same fallback + /// code as the string-like and thrown-handler paths -- so an error state always + /// produces an error response. + template + void set_handler_error(state_view& state, E&& error) + { + using DE = std::remove_cvref_t; + if constexpr (std::same_as) { + if (error == error_code::none) [[unlikely]] { + state.out.set_error(error_code::parse_error, "handler returned an error state with error_code::none"); + } + else { + state.out.set_error(error); + } + } + else if constexpr (std::same_as) { + state.out.set_error(error.ec == error_code::none ? error_code::parse_error : error.ec, + error.custom_error_message); + } + else { + static_assert(std::convertible_to, + "A REPE handler's glz::expected error type must be glz::error_code, glz::error_ctx, " + "or convertible to std::string_view."); + state.out.set_error(error_code::parse_error, std::string_view(error)); + } + } + + /// Write a response value to the output buffer (zero-copy). The value is serialized + /// as-is; a glz::expected is written through its normal JSON serializer, so an error + /// state becomes {"unexpected": ...}. Handler results that should treat a glz::expected + /// error as a request failure go through write_handler_result instead. + template + void write_response(Value&& value, state_view& state) { state.out.reset(state.in); - auto ec = state.out.template set_body(nullptr); + auto ec = state.out.template set_body(std::forward(value)); if (bool(ec)) [[unlikely]] { state.out.set_error(ec, "Failed to serialize response"); } } + + /// Write a registered handler's result. Unlike write_response, a glz::expected return + /// is interpreted as a success/error channel: the success value is serialized and an + /// error is translated into a REPE error response (see set_handler_error), so a handler + /// can signal failure by returning glz::unexpected(...) rather than throwing (required + /// under -fno-exceptions, convenient otherwise). This is applied only at the function/ + /// member-function call sites; a registered data member of type glz::expected is read + /// through write_response and still serializes its JSON shape. See #2265. + template + void write_handler_result(Value&& value, state_view& state) + { + using V = std::remove_cvref_t; + if constexpr (glz::is_expected) { + if (value.has_value()) { + if constexpr (std::is_void_v) { + write_response(state); + } + else { + write_response(*std::forward(value), state); + } + } + else { + state.out.reset(state.in); + set_handler_error(state, std::forward(value).error()); + } + } + else { + write_response(std::forward(value), state); + } + } } diff --git a/include/glaze/rpc/repe/repe_registry_impl.hpp b/include/glaze/rpc/repe/repe_registry_impl.hpp index 73adafafcf..d52a544381 100644 --- a/include/glaze/rpc/repe/repe_registry_impl.hpp +++ b/include/glaze/rpc/repe/repe_registry_impl.hpp @@ -63,7 +63,7 @@ namespace glz std::ignore = func(); return; } - write_response(func(), state); + write_handler_result(func(), state); }; } } @@ -95,7 +95,7 @@ namespace glz } else { auto ret = func(params); - write_response(ret, state); + write_handler_result(ret, state); } }; } @@ -188,7 +188,7 @@ namespace glz return; } - write_response((value.*func)(), state); + write_handler_result((value.*func)(), state); } }; } @@ -220,7 +220,7 @@ namespace glz return; } - write_response((value.*func)(input), state); + write_handler_result((value.*func)(input), state); } }; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 5d039b28b9..556b38aeaf 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -35,6 +35,15 @@ add_library(glz_test_common INTERFACE) target_compile_features(glz_test_common INTERFACE cxx_std_23) target_link_libraries(glz_test_common INTERFACE ut::ut glaze::glaze glaze::asio) +# glz_test_common is the no-exceptions test config: on GCC/Clang it compiles with +# -fno-exceptions, so targets that link it exercise the library's no-exception +# (#else / !__cpp_exceptions) paths. MSVC is the exception: /GR- disables RTTI to mirror +# -fno-rtti, but exceptions stay ENABLED -- CMake injects /EHsc by default, so disabling +# them would also require _HAS_EXCEPTIONS=0 and stripping /EHsc, which would flip the +# entire Windows suite to no-exceptions and surface unguarded throw/try in the MSVC STL, +# ut, and Asio. That is its own effort; until then the *_noexceptions targets are +# exceptions-enabled duplicates on Windows and the !__cpp_exceptions paths go uncompiled +# there. Tracked as a follow-up (proper MSVC _HAS_EXCEPTIONS=0 coverage). if(MSVC) target_compile_options(glz_test_common INTERFACE /GR- /bigobj) target_compile_options(glz_test_common INTERFACE /W4 /wd4459 /wd4805) diff --git a/tests/networking_tests/CMakeLists.txt b/tests/networking_tests/CMakeLists.txt index 0a762bb057..4889b89953 100644 --- a/tests/networking_tests/CMakeLists.txt +++ b/tests/networking_tests/CMakeLists.txt @@ -2,6 +2,7 @@ # glaze_setup_asio() (cmake/glaze-asio.cmake). option(glaze_BUILD_SSL_TESTS "Build SSL/TLS tests (requires OpenSSL headers matching target architecture)" ON) +option(glaze_BUILD_REGISTRY_NOEXCEPT_TESTS "Build no-exceptions variants of the registry tests" ON) # HTTP Examples add_subdirectory(http_examples) @@ -28,6 +29,7 @@ add_subdirectory(repe_buffer_test) add_subdirectory(repe_plugin_test) add_subdirectory(repe_test) add_subdirectory(registry_view_test) +add_subdirectory(registry_no_exceptions_test) add_subdirectory(repe_to_jsonrpc_test) add_subdirectory(rest_test) add_subdirectory(websocket_test) diff --git a/tests/networking_tests/jsonrpc_registry_test/CMakeLists.txt b/tests/networking_tests/jsonrpc_registry_test/CMakeLists.txt index 371a488a6e..d56c6d938f 100644 --- a/tests/networking_tests/jsonrpc_registry_test/CMakeLists.txt +++ b/tests/networking_tests/jsonrpc_registry_test/CMakeLists.txt @@ -5,3 +5,9 @@ add_executable(${PROJECT_NAME} ${PROJECT_NAME}.cpp) target_link_libraries(${PROJECT_NAME} PRIVATE glz_test_exceptions) add_test(NAME ${PROJECT_NAME} COMMAND ${PROJECT_NAME}) + +if(glaze_BUILD_REGISTRY_NOEXCEPT_TESTS) + add_executable(${PROJECT_NAME}_noexceptions ${PROJECT_NAME}.cpp) + target_link_libraries(${PROJECT_NAME}_noexceptions PRIVATE glz_test_common) + add_test(NAME ${PROJECT_NAME}_noexceptions COMMAND ${PROJECT_NAME}_noexceptions) +endif() diff --git a/tests/networking_tests/jsonrpc_registry_test/jsonrpc_registry_test.cpp b/tests/networking_tests/jsonrpc_registry_test/jsonrpc_registry_test.cpp index 300b6999be..2182fa0669 100644 --- a/tests/networking_tests/jsonrpc_registry_test/jsonrpc_registry_test.cpp +++ b/tests/networking_tests/jsonrpc_registry_test/jsonrpc_registry_test.cpp @@ -374,6 +374,10 @@ suite jsonrpc_merge_tests = [] { }; }; +// These tests exercise the exceptions-only safety net that catches a throwing handler. +// Under -fno-exceptions a handler reports failure by returning glz::expected instead +// (see registry_no_exceptions_test), so this struct and suite are exceptions-only. +#if __cpp_exceptions struct throwing_functions_t { std::function throw_func = []() -> int { throw std::runtime_error("Test exception"); }; @@ -411,6 +415,7 @@ suite jsonrpc_exception_tests = [] { expect(!err) << "Response must be valid JSON: " << glz::format_error(err, response); }; }; +#endif suite jsonrpc_error_json_validity_tests = [] { "parse_error_with_special_chars_produces_valid_json"_test = [] { diff --git a/tests/networking_tests/openapi_test/openapi_test.cpp b/tests/networking_tests/openapi_test/openapi_test.cpp index 03d32fd69f..d2f2b121ab 100644 --- a/tests/networking_tests/openapi_test/openapi_test.cpp +++ b/tests/networking_tests/openapi_test/openapi_test.cpp @@ -83,13 +83,35 @@ struct UserService } return false; } + + // Find a user by ID, failing the request with a 404 when absent. Returning + // glz::expected makes the generated OpenAPI document an error response. + glz::expected findUser(const UserIdRequest& request) + { + auto it = users.find(request.id); + if (it != users.end()) { + return it->second; + } + return glz::unexpected(glz::http_error{404, "user not found"}); + } + + // Reset a user's profile. A void return replies 204 No Content, which the + // generated OpenAPI should document under the 204 status rather than 200. + void resetUser(const UserIdRequest& request) + { + if (auto it = users.find(request.id); it != users.end()) { + it->second.name.clear(); + it->second.email.clear(); + } + } }; template <> struct glz::meta { using T = UserService; - static constexpr auto value = object(&T::getAllUsers, &T::getUserById, &T::createUser, &T::deleteUser); + static constexpr auto value = + object(&T::getAllUsers, &T::getUserById, &T::createUser, &T::deleteUser, &T::findUser, &T::resetUser); }; int main() @@ -187,6 +209,24 @@ int main() expect(response.response_body.find("openapi") != std::string::npos); expect(response.response_body.find("User Management API") != std::string::npos); expect(response.response_body.find("paths") != std::string::npos); + + // A reflected handler returning glz::expected documents its error response + // (a glz::http_error body) in the generated spec. findUser returns + // glz::expected, so the status is data-dependent and + // the error is documented under the "default" response key. + expect(response.response_body.find("findUser") != std::string::npos) << "findUser endpoint should appear"; + expect(response.response_body.find("http_error") != std::string::npos) + << "http_error schema should be registered in components"; + expect(response.response_body.find("\"default\"") != std::string::npos) + << "error response should be documented under the default status"; + + // A void handler replies 204 No Content, which the spec should document under + // the 204 status rather than 200. resetUser returns void. + expect(response.response_body.find("resetUser") != std::string::npos) << "resetUser endpoint should appear"; + expect(response.response_body.find("\"204\"") != std::string::npos) + << "void handler should be documented with a 204 response"; + expect(response.response_body.find("No Content") != std::string::npos) + << "204 response should be described as No Content"; } else { std::cout << "HTTP request failed with error code: " << response_result.error().value() << std::endl; diff --git a/tests/networking_tests/registry_no_exceptions_test/CMakeLists.txt b/tests/networking_tests/registry_no_exceptions_test/CMakeLists.txt new file mode 100644 index 0000000000..eb253da809 --- /dev/null +++ b/tests/networking_tests/registry_no_exceptions_test/CMakeLists.txt @@ -0,0 +1,17 @@ +project(registry_no_exceptions_test) + +# Exceptions build: verifies the new APIs behave the same with exceptions enabled. +add_executable(${PROJECT_NAME} ${PROJECT_NAME}.cpp) +target_link_libraries(${PROJECT_NAME} PRIVATE glz_test_exceptions) +add_test(NAME ${PROJECT_NAME} COMMAND ${PROJECT_NAME}) + +# No-exceptions build: the core purpose of this test. On GCC/Clang glz_test_common +# compiles with -fno-exceptions -fno-rtti, so this would not even compile before issue +# #2265 was fixed. On MSVC exceptions stay enabled (see glz_test_common in +# tests/CMakeLists.txt), so this variant mirrors the exceptions build there rather than +# exercising the !__cpp_exceptions paths. +if(glaze_BUILD_REGISTRY_NOEXCEPT_TESTS) + add_executable(${PROJECT_NAME}_noexceptions ${PROJECT_NAME}.cpp) + target_link_libraries(${PROJECT_NAME}_noexceptions PRIVATE glz_test_common) + add_test(NAME ${PROJECT_NAME}_noexceptions COMMAND ${PROJECT_NAME}_noexceptions) +endif() diff --git a/tests/networking_tests/registry_no_exceptions_test/registry_no_exceptions_test.cpp b/tests/networking_tests/registry_no_exceptions_test/registry_no_exceptions_test.cpp new file mode 100644 index 0000000000..ed67755297 --- /dev/null +++ b/tests/networking_tests/registry_no_exceptions_test/registry_no_exceptions_test.cpp @@ -0,0 +1,635 @@ +// Glaze Library +// For the license information refer to glaze.hpp + +// Verifies that glz::registry and glz::http_router work under -fno-exceptions and, +// just as importantly, that errors are never hidden: route-registration conflicts are +// reported by return value (not swallowed to stderr or aborted), and a handler can fail +// a request by returning glz::expected instead of throwing. This translation lets the +// same registered handler report failures with or without exceptions. +// +// This file is compiled twice (see CMakeLists.txt): once with exceptions and once with +// -fno-exceptions, so the behavior is verified to be identical in both configurations. +// See https://github.com/stephenberry/glaze/issues/2265. + +#include +#include +#include + +#include "glaze/net/http_router.hpp" +#include "glaze/rpc/registry.hpp" +#include "ut/ut.hpp" + +using namespace ut; +namespace repe = glz::repe; + +namespace +{ + // Run a pre-built REPE request through the span-based (zero-copy) registry API and + // return the parsed response message. + template + repe::message run_repe(Registry& registry, repe::message& request) + { + std::string request_buffer; + std::string response_buffer; + repe::to_buffer(request, request_buffer); + registry.call(std::span{request_buffer}, response_buffer); + repe::message response{}; + repe::from_buffer(response_buffer, response); + return response; + } +} + +// ---------------------------------------------------------------------------- +// http_router: structural conflicts are surfaced, identical routes overwrite. +// ---------------------------------------------------------------------------- + +suite http_router_registration = [] { + "try_route_success"_test = [] { + glz::http_router router{}; + auto result = router.try_route(glz::http_method::GET, "/users/:id", [](const glz::request&, glz::response&) {}); + expect(result.has_value()); + expect(!router.has_route_error()); + }; + + "try_route_reports_parameter_conflict"_test = [] { + glz::http_router router{}; + std::ignore = router.try_route(glz::http_method::GET, "/users/:id", [](const glz::request&, glz::response&) {}); + // A different parameter name at the same position cannot be represented. + auto result = + router.try_route(glz::http_method::GET, "/users/:name/profile", [](const glz::request&, glz::response&) {}); + expect(!result.has_value()); + expect(result.error().find("different parameter names") != std::string::npos) << result.error(); + // try_* is the explicit return-value path: the caller owns the error, and the + // route_error() side-channel (for the chaining variants) stays clean. + expect(!router.has_route_error()); + }; + + "try_route_reports_wildcard_not_last"_test = [] { + glz::http_router router{}; + auto result = + router.try_route(glz::http_method::GET, "/files/*path/extra", [](const glz::request&, glz::response&) {}); + expect(!result.has_value()); + expect(result.error().find("Wildcard must be the last segment") != std::string::npos) << result.error(); + }; + + "identical_route_overwrites_without_error"_test = [] { + glz::http_router router{}; + auto first = router.try_route(glz::http_method::GET, "/x", [](const glz::request&, glz::response&) {}); + auto second = router.try_route(glz::http_method::GET, "/x", [](const glz::request&, glz::response&) {}); + expect(first.has_value()); + expect(second.has_value()); // overwriting an identical route is intentional, not a conflict + }; + + // try_stream()/try_websocket() give streaming and WebSocket routes the same + // return-value conflict channel that try_route() gives normal routes, and like + // try_route() they report purely by return value without touching route_error(). + "try_stream_reports_conflict_by_return_value"_test = [] { + glz::http_router router{}; + auto ok = router.try_stream(glz::http_method::GET, "/events/:id", glz::streaming_handler{}); + expect(ok.has_value()); + expect(!router.has_route_error()); + // A different parameter name at the same position cannot be represented. + auto conflict = router.try_stream(glz::http_method::GET, "/events/:name/tail", glz::streaming_handler{}); + expect(!conflict.has_value()); + expect(conflict.error().find("different parameter names") != std::string::npos) << conflict.error(); + expect(!router.has_route_error()); // returned by value, not written to the side-channel + }; + + "try_websocket_reports_conflict_by_return_value"_test = [] { + glz::http_router router{}; + auto ok = router.try_websocket("/ws/:id", glz::websocket_handler{}); + expect(ok.has_value()); + expect(!router.has_route_error()); + auto conflict = router.try_websocket("/ws/:name/tail", glz::websocket_handler{}); + expect(!conflict.has_value()); + expect(conflict.error().find("different parameter names") != std::string::npos) << conflict.error(); + expect(!router.has_route_error()); + }; + +#if __cpp_exceptions + "route_throws_on_conflict_when_exceptions_enabled"_test = [] { + glz::http_router router{}; + router.get("/users/:id", [](const glz::request&, glz::response&) {}); + expect(throws([&] { + router.route(glz::http_method::GET, "/users/:name", [](const glz::request&, glz::response&) {}); + })) << "route() must surface a structural conflict loudly when exceptions are enabled"; + }; +#else + "route_records_conflict_on_side_channel_without_exceptions"_test = [] { + glz::http_router router{}; + router.get("/users/:id", [](const glz::request&, glz::response&) {}); + // Under -fno-exceptions the chaining route() cannot throw or return, so it records + // the conflict on the side-channel. This is the path the try_* variants bypass. + router.route(glz::http_method::GET, "/users/:name", [](const glz::request&, glz::response&) {}); + expect(router.has_route_error()); + expect(router.route_error().find("different parameter names") != std::string::npos) << router.route_error(); + }; +#endif +}; + +// ---------------------------------------------------------------------------- +// REPE: a handler may fail a request by returning glz::expected. +// ---------------------------------------------------------------------------- + +struct repe_expected_t +{ + // string error -> error_code::parse_error, matching a thrown handler + std::function(int)> halve = [](int x) -> glz::expected { + if (x % 2 != 0) { + return glz::unexpected(std::string("odd input: ") + std::to_string(x)); + } + return x / 2; + }; + // explicit error_code error channel + std::function(int)> non_negative = + [](int x) -> glz::expected { + if (x < 0) { + return glz::unexpected(glz::error_code::invalid_body); + } + return x; + }; + // glz::error_ctx error channel: both the code and the custom message propagate. + std::function(int)> bounded = [](int x) -> glz::expected { + if (x > 100) { + return glz::unexpected( + glz::error_ctx{.ec = glz::error_code::constraint_violated, .custom_error_message = "out of range"}); + } + return x; + }; + // Degenerate: an error state carrying the success code. Without normalization this + // would be indistinguishable from success on the wire. + std::function()> degenerate_code = []() -> glz::expected { + return glz::unexpected(glz::error_code::none); + }; + std::function()> degenerate_ctx = []() -> glz::expected { + return glz::unexpected(glz::error_ctx{.ec = glz::error_code::none, .custom_error_message = "should still fail"}); + }; +}; + +// A registered data member of type glz::expected is read as a stored value, not a +// handler result. The expected-as-error-channel interception lives in +// write_handler_result (applied only at function call sites), so reading the member +// serializes its JSON shape rather than translating an error state to a protocol error. +struct repe_expected_member_t +{ + glz::expected cached = glz::unexpected(std::string("stale")); +}; + +suite repe_expected_handlers = [] { + "repe_expected_success"_test = [] { + glz::registry server{}; + repe_expected_t obj{}; + server.on(obj); + + repe::message request{}; + repe::request_json({"/halve"}, request, 10); + auto response = run_repe(server, request); + expect(response.header.ec == glz::error_code::none) << response.body; + expect(response.body == "5") << response.body; + }; + + "repe_expected_string_error_maps_to_parse_error_and_preserves_id"_test = [] { + glz::registry server{}; + repe_expected_t obj{}; + server.on(obj); + + repe::message request{}; + repe::request_json({"/halve"}, request, 3); + request.header.id = 4242; + auto response = run_repe(server, request); + // Returning glz::unexpected(string) yields the same code a thrown handler would. + expect(response.header.ec == glz::error_code::parse_error) << response.body; + expect(response.header.id == 4242) << "id must be preserved through a handler error"; + expect(response.body.find("odd input: 3") != std::string::npos) << response.body; + }; + + "repe_expected_error_code_passthrough"_test = [] { + glz::registry server{}; + repe_expected_t obj{}; + server.on(obj); + + repe::message request{}; + repe::request_json({"/non_negative"}, request, -1); + auto response = run_repe(server, request); + expect(response.header.ec == glz::error_code::invalid_body) << response.body; + }; + + "repe_expected_error_ctx_propagates_code_and_message"_test = [] { + glz::registry server{}; + repe_expected_t obj{}; + server.on(obj); + + repe::message request{}; + repe::request_json({"/bounded"}, request, 250); + auto response = run_repe(server, request); + // error_ctx carries both fields: the code lands in the header, the message in the body. + expect(response.header.ec == glz::error_code::constraint_violated) << response.body; + expect(response.body == "out of range") << response.body; + }; + + "repe_degenerate_none_error_still_fails_the_request"_test = [] { + glz::registry server{}; + repe_expected_t obj{}; + server.on(obj); + + // error_code::none in an error state is normalized to parse_error (the thrown- + // handler fallback) so the failure cannot masquerade as success. + repe::message request{}; + repe::request_json({"/degenerate_code"}, request); + auto response = run_repe(server, request); + expect(response.header.ec == glz::error_code::parse_error) << response.body; + + // Same for an error_ctx whose ec is none; the custom message is preserved. + repe::message ctx_request{}; + repe::request_json({"/degenerate_ctx"}, ctx_request); + auto ctx_response = run_repe(server, ctx_request); + expect(ctx_response.header.ec == glz::error_code::parse_error) << ctx_response.body; + expect(ctx_response.body == "should still fail") << ctx_response.body; + }; + + "repe_expected_data_member_is_serialized_not_intercepted"_test = [] { + glz::registry server{}; + repe_expected_member_t obj{}; + server.on(obj); + + repe::message request{}; + repe::request_json({"/cached"}, request); // read the member (no body) + auto response = run_repe(server, request); + // Reading a stored glz::expected is a success that serializes its JSON shape; it is + // NOT reinterpreted as a handler error. + expect(response.header.ec == glz::error_code::none) << response.body; + expect(response.body == R"({"unexpected":"stale"})") << response.body; + }; +}; + +// ---------------------------------------------------------------------------- +// JSON-RPC: glz::expected -> JSON-RPC error translation (issue #2265). +// ---------------------------------------------------------------------------- + +struct jsonrpc_expected_t +{ + std::function(int)> halve = [](int x) -> glz::expected { + if (x % 2 != 0) { + return glz::unexpected(std::string("odd input")); + } + return x / 2; + }; + // Full JSON-RPC fidelity: custom code, message, and data. The glz::rpc::result + // alias and the invalid_params() helper replace the longhand + // glz::unexpected(glz::rpc::error{glz::rpc::error_e::invalid_params, std::optional{...}}). + std::function(int)> checked = [](int x) -> glz::rpc::result { + if (x < 0) { + return glz::rpc::invalid_params("x must be >= 0"); + } + return x; + }; + // glz::error_ctx -> JSON-RPC internal error; the context detail is carried in data. + std::function(int)> bounded = [](int x) -> glz::expected { + if (x > 100) { + return glz::unexpected( + glz::error_ctx{.ec = glz::error_code::constraint_violated, .custom_error_message = "out of range"}); + } + return x; + }; + // Degenerate: an error state carrying the success code (no_error = 0), which would + // emit a malformed JSON-RPC error with "code":0 without normalization. + std::function()> degenerate_error = []() -> glz::rpc::result { + return glz::unexpected(glz::rpc::error{}); // default-constructed: no_error, "No error" + }; + std::function()> degenerate_code = + []() -> glz::expected { return glz::unexpected(glz::rpc::error_e::no_error); }; +}; + +// As with REPE, a registered glz::expected data member is read as a stored value +// (serialized JSON shape), not reinterpreted as a JSON-RPC error. +struct jsonrpc_expected_member_t +{ + glz::expected cached = glz::unexpected(std::string("stale")); +}; + +suite jsonrpc_expected_handlers = [] { + "jsonrpc_expected_success"_test = [] { + glz::registry server{}; + jsonrpc_expected_t obj{}; + server.on(obj); + + auto response = server.call(R"({"jsonrpc":"2.0","method":"halve","params":4,"id":1})"); + expect(response.find(R"("result":2)") != std::string::npos) << response; + }; + + "jsonrpc_expected_string_error_maps_to_internal_error"_test = [] { + glz::registry server{}; + jsonrpc_expected_t obj{}; + server.on(obj); + + auto response = server.call(R"({"jsonrpc":"2.0","method":"halve","params":3,"id":2})"); + expect(response.find(R"("code":-32603)") != std::string::npos) << response; // internal error + // Reserved code -> standard message, handler detail carried in data (mirrors the thrown path). + expect(response.find(R"("message":"Internal error")") != std::string::npos) << response; + expect(response.find(R"("data":"odd input")") != std::string::npos) << response; + expect(response.find(R"("id":2)") != std::string::npos) << response; + }; + + "jsonrpc_expected_rpc_error_full_fidelity"_test = [] { + glz::registry server{}; + jsonrpc_expected_t obj{}; + server.on(obj); + + auto response = server.call(R"({"jsonrpc":"2.0","method":"checked","params":-5,"id":3})"); + expect(response.find(R"("code":-32602)") != std::string::npos) << response; // invalid_params + expect(response.find("x must be >= 0") != std::string::npos) << response; // data passthrough + expect(response.find(R"("id":3)") != std::string::npos) << response; + }; + + "jsonrpc_expected_error_ctx_maps_to_internal_error"_test = [] { + glz::registry server{}; + jsonrpc_expected_t obj{}; + server.on(obj); + + auto response = server.call(R"({"jsonrpc":"2.0","method":"bounded","params":250,"id":7})"); + expect(response.find(R"("code":-32603)") != std::string::npos) << response; // internal error + // Reserved code -> standard message, context detail carried in data (mirrors the thrown path). + expect(response.find(R"("message":"Internal error")") != std::string::npos) << response; + expect(response.find(R"("data":"out of range")") != std::string::npos) << response; + expect(response.find(R"("id":7)") != std::string::npos) << response; + }; + + "jsonrpc_degenerate_no_error_code_is_normalized_to_internal"_test = [] { + glz::registry server{}; + jsonrpc_expected_t obj{}; + server.on(obj); + + // A default-constructed rpc::error (code no_error) is normalized to internal so + // the response is a well-formed JSON-RPC error rather than "code":0. + auto response = server.call(R"({"jsonrpc":"2.0","method":"degenerate_error","id":9})"); + expect(response.find(R"("error")") != std::string::npos) << response; + expect(response.find(R"("code":-32603)") != std::string::npos) << response; + expect(response.find(R"("message":"Internal error")") != std::string::npos) << response; + expect(response.find(R"("code":0)") == std::string::npos) << response; + + // The bare error_e::no_error channel is normalized the same way. + auto code_response = server.call(R"({"jsonrpc":"2.0","method":"degenerate_code","id":10})"); + expect(code_response.find(R"("code":-32603)") != std::string::npos) << code_response; + expect(code_response.find(R"("code":0)") == std::string::npos) << code_response; + }; + + "jsonrpc_expected_data_member_is_serialized_not_intercepted"_test = [] { + glz::registry server{}; + jsonrpc_expected_member_t obj{}; + server.on(obj); + + auto response = server.call(R"({"jsonrpc":"2.0","method":"cached","id":8})"); + // Read as a value: the result is the serialized expected, not a JSON-RPC error object. + expect(response.find(R"("result":{"unexpected":"stale"})") != std::string::npos) << response; + expect(response.find(R"("error")") == std::string::npos) << response; + }; +}; + +// ---------------------------------------------------------------------------- +// REST: try_on() reports registration outcome without throwing. +// ---------------------------------------------------------------------------- + +struct rest_obj_t +{ + int value{}; + std::string name{}; +}; + +suite rest_registration = [] { + "rest_try_on_success"_test = [] { + glz::registry reg{}; + rest_obj_t obj{}; + auto result = reg.try_on(obj); + expect(result.has_value()) << (result ? std::string{} : std::string(result.error())); + expect(!reg.endpoints.has_route_error()); + expect(!reg.endpoints.routes().empty()) << "registration must create routes"; + // A registered member route must be reachable after registration. + auto [handler, params] = reg.endpoints.match(glz::http_method::GET, "/value"); + expect(bool(handler)) << "registered member route should be reachable"; + }; +}; + +// ---------------------------------------------------------------------------- +// Compile-time guard: merge registration is available for REPE/JSON-RPC but not +// REST. REST's registry_impl has no register_merge_endpoint, so on(merge) and +// try_on(merge) are constrained out (requires Proto != REST) and produce an honest +// "no matching function" diagnostic instead of a hard error deep in the +// implementation. These checks lock the constraint in: they fail to compile if it +// is dropped, and they will flag it if REST merge is ever implemented (at which +// point the constraint, and these asserts, should be revisited). See issue #2265. +// ---------------------------------------------------------------------------- + +namespace merge_constraint_checks +{ + struct merge_a_t + { + int x{}; + }; + struct merge_b_t + { + int y{}; + }; + + template + using reg_t = glz::registry; + + template + concept can_on_merge = requires(Reg reg, glz::merge m) { reg.on(m); }; + template + concept can_try_on_merge = requires(Reg reg, glz::merge m) { reg.try_on(m); }; + + static_assert(!can_on_merge>, "REST must not advertise on(merge)"); + static_assert(!can_try_on_merge>, "REST must not advertise try_on(merge)"); + static_assert(can_on_merge>, "REPE must support on(merge)"); + static_assert(can_try_on_merge>, "REPE must support try_on(merge)"); + static_assert(can_on_merge>, "JSON-RPC must support on(merge)"); + static_assert(can_try_on_merge>, "JSON-RPC must support try_on(merge)"); +} + +// ---------------------------------------------------------------------------- +// REST: a reflected handler may fail a request by returning glz::expected. The +// registry maps the error to an HTTP status + glz::http_error body rather than +// serializing the expected as a 200 success payload. See issue #2265. +// ---------------------------------------------------------------------------- + +struct rest_expected_t +{ + // POST /halve : string error -> 500 (server-side) by default. + std::function(int)> halve = [](int x) -> glz::expected { + if (x % 2 != 0) return glz::unexpected(std::string("odd input")); + return x / 2; + }; + + // POST /non_negative : glz::error_code -> mapped HTTP status (invalid_body -> 400). + std::function(int)> non_negative = + [](int x) -> glz::expected { + if (x < 0) return glz::unexpected(glz::error_code::invalid_body); + return x; + }; + + // POST /find : glz::http_error -> explicit status + message (full control). + std::function(int)> find = [](int id) -> glz::expected { + if (id != 42) return glz::unexpected(glz::http_error{404, "not found"}); + return id; + }; + + // GET /ping : no-arg function endpoint that fails with an explicit status. + std::function()> ping = + []() -> glz::expected { + return glz::unexpected(glz::http_error{503, "warming up"}); + }; + + // POST /reset : expected -> 204 on success, mapped status on failure. + std::function(int)> reset = [](int code) -> glz::expected { + if (code != 0) return glz::unexpected(std::string("bad code")); + return {}; + }; + + // POST /bounded : glz::error_ctx -> 500 with the formatted context as the message. + std::function(int)> bounded = [](int x) -> glz::expected { + if (x > 100) + return glz::unexpected( + glz::error_ctx{.ec = glz::error_code::constraint_violated, .custom_error_message = "out of range"}); + return x; + }; + + // GET /degenerate : an error state carrying a success status, which would look like + // a successful response on the wire without normalization. + std::function()> degenerate = []() -> glz::expected { + return glz::unexpected(glz::http_error{200, "error disguised as success"}); + }; +}; + +// As with REPE and JSON-RPC, a registered glz::expected data member is read as a stored +// value (serialized JSON shape), not reinterpreted as an HTTP error. REST never had the +// #2265 bug because it keeps the stored-value path separate from handler-result writing, +// but nothing asserted that until now. +struct rest_expected_member_t +{ + glz::expected cached = glz::unexpected(std::string("stale")); +}; + +namespace +{ + // Invoke a registered REST route directly (no live server) and return the response. + glz::response run_rest(glz::registry& reg, glz::http_method method, std::string_view path, + std::string body) + { + auto [handler, params] = reg.endpoints.match(method, path); + expect(bool(handler)) << "route should be reachable: " << path; + glz::request req{ + .method = method, .target = std::string(path), .params = std::move(params), .body = std::move(body)}; + glz::response res{}; + if (handler) { + handler(req, res); + } + return res; + } +} + +suite rest_expected_handlers = [] { + "rest_expected_success_serializes_value"_test = [] { + glz::registry reg{}; + rest_expected_t obj{}; + reg.on(obj); + auto res = run_rest(reg, glz::http_method::POST, "/halve", "4"); + expect(res.status_code == 200) << res.status_code; + expect(res.response_body == "2") << res.response_body; // unwrapped value, not {"unexpected":..} + }; + + "rest_expected_string_error_defaults_to_500"_test = [] { + glz::registry reg{}; + rest_expected_t obj{}; + reg.on(obj); + auto res = run_rest(reg, glz::http_method::POST, "/halve", "3"); + expect(res.status_code == 500) << res.status_code; + glz::http_error err{}; + expect(!glz::read_json(err, res.response_body)) << res.response_body; + expect(err.status == 500); + expect(err.message == "odd input") << res.response_body; + }; + + "rest_expected_error_code_maps_to_status"_test = [] { + glz::registry reg{}; + rest_expected_t obj{}; + reg.on(obj); + auto res = run_rest(reg, glz::http_method::POST, "/non_negative", "-1"); + expect(res.status_code == 400) << res.status_code; // invalid_body -> 400 + glz::http_error err{}; + expect(!glz::read_json(err, res.response_body)) << res.response_body; + expect(err.status == 400); + }; + + "rest_expected_http_error_full_control"_test = [] { + glz::registry reg{}; + rest_expected_t obj{}; + reg.on(obj); + auto res = run_rest(reg, glz::http_method::POST, "/find", "7"); + expect(res.status_code == 404) << res.status_code; + glz::http_error err{}; + expect(!glz::read_json(err, res.response_body)) << res.response_body; + expect(err.status == 404); + expect(err.message == "not found") << res.response_body; + // The success branch (id == 42) returns the unwrapped int body with a 200 status. + auto ok = run_rest(reg, glz::http_method::POST, "/find", "42"); + expect(ok.status_code == 200) << ok.status_code; + expect(ok.response_body == "42") << ok.response_body; + }; + + "rest_expected_get_no_arg_failure"_test = [] { + glz::registry reg{}; + rest_expected_t obj{}; + reg.on(obj); + auto res = run_rest(reg, glz::http_method::GET, "/ping", ""); + expect(res.status_code == 503) << res.status_code; + }; + + "rest_expected_void_success_is_204"_test = [] { + glz::registry reg{}; + rest_expected_t obj{}; + reg.on(obj); + auto ok = run_rest(reg, glz::http_method::POST, "/reset", "0"); + expect(ok.status_code == 204) << ok.status_code; + auto bad = run_rest(reg, glz::http_method::POST, "/reset", "1"); + expect(bad.status_code == 500) << bad.status_code; + }; + + "rest_expected_error_ctx_maps_to_500_with_formatted_message"_test = [] { + glz::registry reg{}; + rest_expected_t obj{}; + reg.on(obj); + auto res = run_rest(reg, glz::http_method::POST, "/bounded", "250"); + expect(res.status_code == 500) << res.status_code; + glz::http_error err{}; + expect(!glz::read_json(err, res.response_body)) << res.response_body; + expect(err.status == 500); + expect(err.message.find("out of range") != std::string::npos) << res.response_body; + }; + + "rest_degenerate_success_status_error_is_normalized_to_500"_test = [] { + glz::registry reg{}; + rest_expected_t obj{}; + reg.on(obj); + // A glz::http_error with a non-error status (< 400) is normalized to 500 so the + // failure cannot masquerade as a success or redirect; the message is preserved. + auto res = run_rest(reg, glz::http_method::GET, "/degenerate", ""); + expect(res.status_code == 500) << res.status_code; + glz::http_error err{}; + expect(!glz::read_json(err, res.response_body)) << res.response_body; + expect(err.status == 500); + expect(err.message == "error disguised as success") << res.response_body; + }; + + "rest_expected_data_member_is_serialized_not_intercepted"_test = [] { + glz::registry reg{}; + rest_expected_member_t obj{}; + reg.on(obj); + // GET the member: reading a stored glz::expected is a success that serializes its + // JSON shape with a 2xx status; it is NOT translated into an HTTP error status. + auto res = run_rest(reg, glz::http_method::GET, "/cached", ""); + expect(res.status_code == 200) << res.status_code; + expect(res.response_body == R"({"unexpected":"stale"})") << res.response_body; + }; +}; + +int main() { return 0; } diff --git a/tests/networking_tests/registry_view_test/CMakeLists.txt b/tests/networking_tests/registry_view_test/CMakeLists.txt index 915702d784..9f1807730f 100644 --- a/tests/networking_tests/registry_view_test/CMakeLists.txt +++ b/tests/networking_tests/registry_view_test/CMakeLists.txt @@ -5,3 +5,9 @@ add_executable(${PROJECT_NAME} ${PROJECT_NAME}.cpp) target_link_libraries(${PROJECT_NAME} PRIVATE glz_test_exceptions) add_test(NAME ${PROJECT_NAME} COMMAND ${PROJECT_NAME}) + +if(glaze_BUILD_REGISTRY_NOEXCEPT_TESTS) + add_executable(${PROJECT_NAME}_noexceptions ${PROJECT_NAME}.cpp) + target_link_libraries(${PROJECT_NAME}_noexceptions PRIVATE glz_test_common) + add_test(NAME ${PROJECT_NAME}_noexceptions COMMAND ${PROJECT_NAME}_noexceptions) +endif() diff --git a/tests/networking_tests/repe_test/CMakeLists.txt b/tests/networking_tests/repe_test/CMakeLists.txt index c8e27cc2dd..443942bc56 100644 --- a/tests/networking_tests/repe_test/CMakeLists.txt +++ b/tests/networking_tests/repe_test/CMakeLists.txt @@ -7,3 +7,9 @@ add_executable(${PROJECT_NAME} ${PROJECT_NAME}.cpp) target_link_libraries(${PROJECT_NAME} PRIVATE glz_test_exceptions) add_test(NAME ${PROJECT_NAME} COMMAND ${PROJECT_NAME}) + +if(glaze_BUILD_REGISTRY_NOEXCEPT_TESTS) + add_executable(${PROJECT_NAME}_noexceptions ${PROJECT_NAME}.cpp) + target_link_libraries(${PROJECT_NAME}_noexceptions PRIVATE glz_test_common) + add_test(NAME ${PROJECT_NAME}_noexceptions COMMAND ${PROJECT_NAME}_noexceptions) +endif() diff --git a/tests/networking_tests/repe_test/repe_test.cpp b/tests/networking_tests/repe_test/repe_test.cpp index a213cd5a81..6d4d68c3ab 100644 --- a/tests/networking_tests/repe_test/repe_test.cpp +++ b/tests/networking_tests/repe_test/repe_test.cpp @@ -712,11 +712,16 @@ suite validation_tests = [] { }; }; -// Define throwing_functions_t at namespace scope for proper linkage +// Define throwing_functions_t at namespace scope for proper linkage. +// Exceptions-only: this exercises the safety net that catches a throwing handler. Under +// -fno-exceptions a handler reports failure by returning glz::expected instead (see +// registry_no_exceptions_test). +#if __cpp_exceptions struct throwing_functions_t { std::function throw_func = []() -> int { throw std::runtime_error("Test exception"); }; }; +#endif suite id_preservation_tests = [] { using namespace test_helpers; @@ -739,6 +744,7 @@ suite id_preservation_tests = [] { expect(response.body.find("invalid_query") != std::string::npos); }; +#if __cpp_exceptions "exception_error_preserves_id"_test = [] { glz::registry server{}; @@ -756,6 +762,7 @@ suite id_preservation_tests = [] { expect(response.header.id == 67890) << "ID should be preserved in exception error"; expect(response.body.find("Test exception") != std::string::npos); }; +#endif "header_validation_errors_preserve_id"_test = [] { glz::registry server{};