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