Skip to main content

cratestack_core/
transport.rs

1//! Transport-binding wire shapes shared by every generator (REST,
2//! RPC) and every server emitter.
3
4/// Wire-level capabilities for one route under a REST binding.
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub struct RouteTransportCapabilities {
7    pub request_types: &'static [&'static str],
8    pub response_types: &'static [&'static str],
9    pub default_response_type: &'static str,
10    pub supports_sequence_response: bool,
11}
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub struct RouteTransportDescriptor {
15    pub name: &'static str,
16    pub method: &'static str,
17    pub path: &'static str,
18    pub capabilities: RouteTransportCapabilities,
19    /// Whether the dispatcher should treat this route as participating in
20    /// rate limiting. `true` for every route by default; `false` only for
21    /// a procedure marked `@no_rate_limit` in a schema that declares
22    /// `extension rate_limit { }` (`docs/design/extensions.md` §5) — model
23    /// CRUD routes have no opt-out today and are always `true`. Mirrors
24    /// `OpDescriptor::rate_limited_by_default` (cratestack#474) so REST
25    /// and RPC transports carry the same fact about the same op, even
26    /// though only one of `ROUTE_TRANSPORTS`/`OPS` is ever populated for
27    /// a given schema. This is participation only: it carries no
28    /// burst/refill/window numbers, and changes nothing about whether
29    /// `RateLimitLayer` is actually wired up at runtime.
30    pub rate_limited_by_default: bool,
31}
32
33/// Wire-shape of a single op in a `transport rpc` schema. See
34/// `docs/design/rpc-transport.md` for the full design — in short, an
35/// op is the dispatch unit shared by every RPC binding (HTTP unary,
36/// HTTP batch, HTTP stream, WebSocket). The macro emits one
37/// `OpDescriptor` per CRUD verb and per procedure when
38/// `Schema.transport == TransportStyle::Rpc`.
39///
40/// REST schemas continue to emit [`RouteTransportDescriptor`] instead;
41/// nothing emits both.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub struct OpDescriptor {
44    /// Stable dotted id, e.g. `"model.User.list"` or
45    /// `"procedure.publishPost"`. This is the only dispatch key —
46    /// same string appears in URLs (`POST /rpc/:op_id`), in
47    /// batch/WS `Request.op` fields, and in generated client SDK
48    /// call sites.
49    pub op_id: &'static str,
50    pub kind: OpKind,
51    /// Schema-level name of the input type (e.g. `"PublishPostInput"`).
52    /// Empty string when the op takes no input.
53    pub input_ty: &'static str,
54    /// Schema-level name of the output type. Empty string when the
55    /// op returns nothing (e.g. `delete` with no echo).
56    pub output_ty: &'static str,
57    /// Whether the op can be safely retried without an idempotency
58    /// key. True for reads and pure procedures; false for mutations.
59    pub idempotent_by_default: bool,
60    /// Whether the dispatcher should treat this op as participating in
61    /// rate limiting. `true` for every op by default; `false` only for a
62    /// procedure marked `@no_rate_limit` in a schema that declares
63    /// `extension rate_limit { }` (`docs/design/extensions.md` §5) — model
64    /// CRUD ops have no opt-out today and are always `true`. This is
65    /// participation only: it carries no burst/refill/window numbers, and
66    /// changes nothing about whether `RateLimitLayer` is actually wired up
67    /// at runtime, mirroring how `idempotent_by_default` above describes a
68    /// fact about the op rather than configuring anything.
69    pub rate_limited_by_default: bool,
70    pub auth_required: bool,
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74#[non_exhaustive]
75pub enum OpKind {
76    /// One input, one output. The common case — every CRUD verb and
77    /// every non-streaming procedure.
78    Unary,
79    /// One input, a finite sequence of outputs. Used for `@stream`
80    /// procedures and (future) streamed `list`. Terminates server-side.
81    Sequence,
82    /// No input, an open-ended sequence of outputs ended only by
83    /// backpressure overflow or client disconnect. Emitted for
84    /// `model.<X>.subscribe` when a model declares `@@subscribe`.
85    /// Dispatched today via SSE (`GET /rpc/subscribe/{op_id}`, design
86    /// doc §3.4a) — the recommended first binding per issue #183's
87    /// spike decision; WebSocket (§3.4) remains speced but unbuilt,
88    /// gated on a real bidirectional/high-multiplexing need. Both
89    /// bindings share the same fire-and-forget semantics: no cursors,
90    /// no replay buffer.
91    Subscription,
92}
93
94impl OpKind {
95    pub const fn as_str(&self) -> &'static str {
96        match self {
97            OpKind::Unary => "unary",
98            OpKind::Sequence => "sequence",
99            OpKind::Subscription => "subscription",
100            #[allow(unreachable_patterns)]
101            _ => "unknown",
102        }
103    }
104}
105
106/// Canonical string assembled by the envelope signing path:
107/// `METHOD\nPATH\nQUERY\nCONTENT-TYPE\nbody-hex`. Both seal and verify
108/// reconstruct the same string from the same inputs.
109pub fn canonical_request_string(
110    method: &str,
111    path: &str,
112    canonical_query: Option<&str>,
113    content_type: Option<&str>,
114    body: &[u8],
115) -> String {
116    let query = canonical_query.unwrap_or_default();
117    let content_type = content_type.unwrap_or_default();
118    let body_hex = body
119        .iter()
120        .map(|byte| format!("{byte:02x}"))
121        .collect::<String>();
122    format!("{method}\n{path}\n{query}\n{content_type}\n{body_hex}")
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn op_kind_as_str() {
131        assert_eq!(OpKind::Unary.as_str(), "unary");
132        assert_eq!(OpKind::Sequence.as_str(), "sequence");
133        assert_eq!(OpKind::Subscription.as_str(), "subscription");
134    }
135
136    #[test]
137    fn op_kind_equality() {
138        assert_eq!(OpKind::Unary, OpKind::Unary);
139        assert_ne!(OpKind::Unary, OpKind::Sequence);
140        assert_ne!(OpKind::Sequence, OpKind::Subscription);
141    }
142
143    #[test]
144    fn canonical_request_string_empty() {
145        let result = canonical_request_string("GET", "/api/users", None, None, b"");
146        assert_eq!(result, "GET\n/api/users\n\n\n");
147    }
148
149    #[test]
150    fn canonical_request_string_with_query_and_content_type() {
151        let result = canonical_request_string(
152            "POST",
153            "/api/users",
154            Some("id=123"),
155            Some("application/json"),
156            b"test",
157        );
158        assert_eq!(
159            result,
160            "POST\n/api/users\nid=123\napplication/json\n74657374"
161        );
162    }
163}