Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
ef9b57a
test(codec): add regression test for all-default messages
polaz Sep 26, 2026
94ad089
fix(codec): decode all-default messages instead of dropping them
polaz Sep 26, 2026
4d5e60c
feat(transcode): return google.rpc.Status details in REST errors
polaz Sep 26, 2026
3600e07
test: share the tonic upstream harness between integration tests
polaz Sep 26, 2026
9aec509
test(transcode): add regression test for streaming request mapping
polaz Sep 26, 2026
2ca7f0f
fix(transcode): map the request onto server-streaming calls
polaz Sep 26, 2026
538b8df
feat(transcode): frame NDJSON error lines and share the error body
polaz Sep 26, 2026
ef15bbe
fix(transcode): fail safely on a malformed upstream error status
polaz Sep 26, 2026
9f9c9f5
feat(transcode): choose error details through the builder, keep the A…
polaz Sep 26, 2026
657a879
feat(transcode): keep unknown detail types in a separate opaqueDetail…
polaz Sep 26, 2026
6845ac0
fix(transcode): harden detail rendering against misleading statuses
polaz Sep 26, 2026
4a7d67c
fix(transcode): resolve and redact details packed inside other details
polaz Sep 26, 2026
8ed1fef
feat(transcode): opt-in NDJSON envelope and YAML transcoding settings
polaz Sep 26, 2026
668408a
fix(transcode): per-type canonical fallback and Any-aware DebugInfo r…
polaz Sep 26, 2026
1f84239
feat(config): warn about unknown top-level config keys
polaz Sep 26, 2026
593efed
fix(transcode): withhold fields typed as DebugInfo, not only packed ones
polaz Sep 26, 2026
396793c
fix(transcode): scrub DebugInfo from extensions and required fields
polaz Sep 26, 2026
9605a1a
fix(transcode): reject details lacking required fields
polaz Sep 26, 2026
7a159ae
fix(transcode): end the stream body right after the terminal frame
polaz Sep 26, 2026
16ef34c
fix(transcode): withhold unknown detail types unless opted in
polaz Sep 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ bytes = "1"
# gRPC client (to upstream service)
tonic = "0.14"
tonic-health = "0.14"
# Canonical google.rpc.Status / error_details descriptors (FILE_DESCRIPTOR_SET)
# and the Status message used to decode `grpc-status-details-bin`, so REST error
# bodies can render typed details even when the product descriptors do not
# import google/rpc/*.
tonic-types = "0.14"
prost = "0.14"

# Async runtime
Expand Down Expand Up @@ -147,3 +152,6 @@ ed25519-dalek = "3"
rand = "0.10"
chrono = "0.4"
tokio-stream = "0.1"
# Compiles the in-memory test protos (google.api.http routes) of the
# error-details integration test without a protoc binary.
protox = "0.9"
183 changes: 180 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Works with **any** gRPC service via proto descriptor files. No code generation,
- **Auto-generated OpenAPI** documentation from proto messages, served at `/openapi.json`
- **Server-streaming** RPC → NDJSON by default, or Server-Sent Events via `Accept: text/event-stream` negotiation
- **gRPC → HTTP status mapping** following the standard `google.rpc.Code` table
- **Typed error details**: the upstream's `google.rpc.Status` details (`ErrorInfo`, `BadRequest`, `RetryInfo`, ...) reach the HTTP client as ProtoJSON, switchable globally and per route (see [Error responses](#error-responses))
- **Header forwarding** from HTTP requests to gRPC metadata (configurable allow-list)
- **Context propagation**: W3C trace-context (`traceparent` forwarded or synthesized) and client deadlines (`grpc-timeout`) carried across the REST↔gRPC boundary
- **Path aliasing** for route remapping (e.g. `/oauth2/*` → `/v1/oauth2/*`)
Expand Down Expand Up @@ -106,6 +107,24 @@ streaming:
# SSE keep-alive interval (seconds). Comment frames keep idle streams alive
# through load balancers / nginx read timeouts. Default: 15.
sse_keep_alive_secs: 15
# Wrap every NDJSON line as {"result": ...} / {"error": ...} (see "Error
# responses"). Default: false.
ndjson_envelope: false

# Optional: google.rpc.Status details in error bodies (see "Error responses").
# On everywhere by default. `opaque` forwards details of types no descriptor
# describes as `opaqueDetails` instead of withholding them (off by default).
# Rules are checked in order; for each switch a rule sets, the first rule whose
# pattern matches the mounted route decides; `*` stays within one path segment
# (a path parameter counts as one), `**` spans segments.
error_details:
enabled: true
opaque: false
routes:
- pattern: "/v1/internal/**"
enabled: false
- pattern: "/v1/partner/**"
opaque: true

# Rate limiting (Shield)
#
Expand Down Expand Up @@ -245,18 +264,174 @@ there is no boundary burst on top of this lag.
See the `shield:` block under [Configuration](#configuration) for the full
schema.

## Error responses

A failed gRPC call becomes a JSON body with the status of the gRPC → HTTP
mapping (`INVALID_ARGUMENT` → 400, `NOT_FOUND` → 404, ...):

```json
{
"error": "INVALID_ARGUMENT",
"code": 3,
"message": "invalid email",
"details": [
{
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
"reason": "EMAIL_TAKEN",
"domain": "identity.example.com",
"metadata": { "email": "a@b.c" }
},
{
"@type": "type.googleapis.com/google.rpc.BadRequest",
"fieldViolations": [{ "field": "email", "description": "already registered" }]
}
]
}
```

- `code`, `message` and `details` follow `google.rpc.Status`; `error` is the
code's name. A client that parses the body as `google.rpc.Status` with a
strict ProtoJSON parser must let it ignore unknown fields.
- `details` is the upstream's `grpc-status-details-bin` trailer, one entry per
`Any`, in [ProtoJSON](https://protobuf.dev/programming-guides/json/#any)
form: `@type` plus the message fields, or `@type` plus `value` for a
well-known type with a special JSON representation (`google.protobuf.Duration`
as `"1.500s"`). Types resolve from the service's descriptors first, then from
the canonical `google/rpc/status.proto` and `error_details.proto`, which are
always available. An upstream that sends no trailer yields `"details": []`.
- `google.rpc.DebugInfo` is never forwarded: it carries stack traces and server
internals meant for the service's operators. It is removed wherever the
proxy knows the schema; a detail whose type is in neither descriptor set
cannot be checked, so it is withheld too unless the opaque-detail extension
below is switched on.
- Details are on for every route. They can be switched off globally or per
route (see below); on such a route the `details` and `opaqueDetails` keys are
absent and the body is `{"error", "code", "message"}`.
- Errors the proxy raises itself on a transcoded route use the same body: a
request that cannot be mapped onto the RPC (`INVALID_ARGUMENT`, 400), an
upstream that is not reachable (`UNAVAILABLE`, 503), a response that cannot
be serialized (`INTERNAL`, 500). Their `details` is empty.
- A broken upstream error status is never passed on in part or reinterpreted:
a trailer that is not a `google.rpc.Status` or disagrees with `grpc-status` /
`grpc-message`, a type URL without a `/` or whose last segment is not a
protobuf full name, or a detail of a known type whose bytes do not decode or
whose value has no valid JSON form (a `Duration` beyond its range), turns the
whole error into
`{"error": "INTERNAL", "code": 13, "message": "upstream returned a malformed error status", "details": []}`
(500, or the terminal frame of a started stream). The cause is logged by the
proxy and not sent to the client. With details switched off for a route the
trailer is not read, so this does not apply there.

**Opaque-detail extension.** ProtoJSON cannot represent an `Any` whose type is
unknown to the writer, so a detail whose type is in neither descriptor set has
no place in `details`. By default such a detail is withheld: its bytes cannot
be inspected, and a `DebugInfo` in one of its fields would otherwise reach the
client. When switched on (`opaque: true`, globally or in a route rule, or
`ErrorDetailsPolicy::with_opaque_details` / `opaque_route`), structured-proxy
keeps it in a separate `opaqueDetails` array instead. Switch it on only for
upstreams trusted not to nest a `DebugInfo` in types the proxy has no
descriptor for. The array is structured-proxy's own extension and **not** part
of ProtoJSON or `google.rpc.Status`:

```json
{
"error": "FAILED_PRECONDITION",
"code": 9,
"message": "quota exhausted",
"details": [
{ "@type": "type.googleapis.com/google.rpc.ErrorInfo", "reason": "QUOTA", "domain": "acme.example.com" }
],
"opaqueDetails": [
{ "index": 1, "typeUrl": "type.googleapis.com/acme.v1.QuotaTicket", "bytes": "CgNULTE=" }
]
}
```

- `typeUrl` is the original type URL and `bytes` the standard base64 of the
original bytes. An entry has no `@type` and never appears in `details`, so
`details` stays an array of ProtoJSON `Any` and nothing in the extension can
be taken for a message of the named type.
- `index` is the entry's position among the forwarded details: merging
`details` and `opaqueDetails` by position restores the upstream's order.
- `opaqueDetails` appears only when at least one detail went there. A client
that knows the type base64-decodes `bytes` and parses the protobuf itself; a
client that does not handle the extension ignores the key.
- Only an unknown type goes there. A detail of a known type that fails to
decode is a broken upstream status (see above), never an opaque entry.

**Errors in server-streaming responses.** An upstream that rejects the call
outright (a gRPC trailers-only response, with no response headers or messages)
gets the mapped HTTP status and the body above. Once the upstream has accepted
the call, the proxy answers `200` and starts the stream right away, so that
headers and SSE keep-alives are not held back waiting for the first message.
Any later failure, including one that arrives before the first message, is then
delivered as a terminal frame whose payload is exactly that body, after which
the stream ends and no further data follows:

- **NDJSON**: the last line, framed by an extra
`"@type": "type.googleapis.com/google.rpc.Status"` next to the error body. A
data line is the ProtoJSON of a response message, which has a top-level
`@type` only when the RPC streams `google.protobuf.Any`, while `Struct`,
`Value` and `ListValue` messages can carry any key at all. For those RPCs no
in-band marker is collision-free: set `streaming.ndjson_envelope: true` (or
`ProxyServer::with_ndjson_envelope(true)`) and every line is wrapped instead,
`{"result": <message>}` for data and `{"error": <error body>}` for the
terminal error, the grpc-gateway stream shape. The envelope changes data
lines too, so it is off by default.
- **SSE**: one event with type `stream-error` (listen with
`addEventListener("stream-error", ...)`), distinct from the `EventSource`
`onerror` that fires on transport failures. The event type is the framing,
so the event data is exactly the error body, without the NDJSON marker.

The same applies to a message the proxy cannot serialize mid-stream: the
stream ends with an `INTERNAL` terminal frame.

This is the HTTP/JSON transcoding format. It is not the Connect protocol's error
format, and it is not an OAuth 2.0 token endpoint error body (RFC 6749 §5.2).

**Switching details off.** In the config file, `error_details:` (see
[Configuration](#configuration)) is read by the standalone binary and by
`ProxyServer::from_yaml_str` / `ProxyServer::from_file`; it is not part of
`ProxyConfig`, so `ProxyConfig::from_yaml_str` alone ignores it. Both log a
warning for a top-level or `streaming:` key no setting reads, so a misspelled
`error_detail:` or `ndjson_envelop:` shows up at startup instead of silently
leaving the default in force. An embedding
service can choose in code with `ProxyServer::with_error_details`. Overrides are
checked in the order they are added; for each switch (`enabled`, `opaque`) the
first rule whose pattern matches the mounted route and that sets the switch
decides, otherwise the global value; `*` stays within one path segment (a path
parameter counts as one) and `**` spans segments. A config rule that sets
neither switch is rejected:

```rust
use structured_proxy::transcode::error::ErrorDetailsPolicy;
use structured_proxy::{config::ProxyConfig, ProxyServer};

# fn build(config: ProxyConfig) -> Result<ProxyServer, String> {
// Details everywhere except the internal admin surface.
let policy = ErrorDetailsPolicy::default().route("/v1/admin/**", false)?;
// Or: off everywhere except a public sub-route.
// let policy = ErrorDetailsPolicy::disabled().route("/v1/public/**", true)?;
// Unknown detail types as `opaqueDetails` for one trusted partner surface.
let policy = policy.opaque_route("/v1/partner/**", true)?;
Ok(ProxyServer::from_config(config).with_error_details(policy))
# }
```

## Library Usage

```rust
use std::path::Path;
use structured_proxy::{config::ProxyConfig, ProxyServer};
use structured_proxy::ProxyServer;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
let config = ProxyConfig::from_file(Path::new("my-service.yaml"))?;
// Reads the whole config file, including `error_details` and
// `streaming.ndjson_envelope`, which live outside `ProxyConfig`.
let server = ProxyServer::from_file(Path::new("my-service.yaml"))?;

// Run the proxy on the configured listen address.
ProxyServer::from_config(config).serve().await?;
server.serve().await?;
Ok(())
}
```
Expand Down Expand Up @@ -324,6 +499,8 @@ The hooks are:
discovery.
- **`with_extra_routes`** — registers extra stateless routes through a
framework-agnostic adapter (request parts in, response parts out).
- **`with_error_details`** — chooses which transcoded routes return the
upstream's `google.rpc.Status` details (see [Error responses](#error-responses)).

## JWT verification

Expand Down
Loading
Loading