fraiseql_core/runtime/relay.rs
1//! Relay cursor encoding and decoding.
2//!
3//! FraiseQL uses two kinds of cursors:
4//!
5//! ## Edge Cursor (keyset pagination)
6//!
7//! Used in `XxxConnection.edges[].cursor` for forward/backward pagination.
8//! Encodes the BIGINT primary key (`pk_{type}`) as `base64(pk_value_decimal_string)`.
9//!
10//! Example: `pk_user = 42` → cursor = `base64("42")` = `"NDI="`
11//!
12//! ## Node ID (global object identification)
13//!
14//! Used in the `Node.id` field and the `node(id: ID!)` global query.
15//! Encodes type name + UUID as `base64("TypeName:uuid")`.
16//!
17//! Example: User with UUID `"550e8400-..."` → `base64("User:550e8400-...")`.
18//!
19//! ## Relay spec references
20//!
21//! - [Global Object Identification](https://relay.dev/graphql/objectidentification.htm)
22//! - [Cursor Connections](https://relay.dev/graphql/connections.htm)
23
24use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
25
26/// Encode a BIGINT primary key value as a Relay edge cursor.
27///
28/// The cursor is `base64(pk_string)` where `pk_string` is the decimal
29/// representation of the BIGINT. This is opaque to the client.
30///
31/// # Example
32///
33/// ```
34/// use fraiseql_core::runtime::relay::encode_edge_cursor;
35///
36/// let cursor = encode_edge_cursor(42);
37/// assert_eq!(cursor, base64_of("42"));
38/// # fn base64_of(s: &str) -> String {
39/// # use base64::{Engine as _, engine::general_purpose::STANDARD};
40/// # STANDARD.encode(s)
41/// # }
42/// ```
43#[must_use]
44pub fn encode_edge_cursor(pk: i64) -> String {
45 BASE64.encode(pk.to_string())
46}
47
48/// Decode a Relay edge cursor back to a BIGINT primary key value.
49///
50/// Returns `None` if the cursor is not valid base64 or does not contain a
51/// valid decimal integer.
52///
53/// # Example
54///
55/// ```
56/// use fraiseql_core::runtime::relay::{decode_edge_cursor, encode_edge_cursor};
57///
58/// let cursor = encode_edge_cursor(42);
59/// assert_eq!(decode_edge_cursor(&cursor), Some(42));
60/// assert_eq!(decode_edge_cursor("not-valid-base64!!"), None);
61/// ```
62#[must_use]
63pub fn decode_edge_cursor(cursor: &str) -> Option<i64> {
64 let bytes = BASE64.decode(cursor).ok()?;
65 let s = std::str::from_utf8(&bytes).ok()?;
66 s.parse::<i64>().ok()
67}
68
69/// Encode a UUID string as a Relay edge cursor.
70///
71/// The cursor is `base64(uuid_string)`, opaque to the client.
72///
73/// # Example
74///
75/// ```
76/// use fraiseql_core::runtime::relay::{decode_uuid_cursor, encode_uuid_cursor};
77///
78/// let uuid = "550e8400-e29b-41d4-a716-446655440000";
79/// let cursor = encode_uuid_cursor(uuid);
80/// assert_eq!(decode_uuid_cursor(&cursor), Some(uuid.to_string()));
81/// ```
82#[must_use]
83pub fn encode_uuid_cursor(uuid: &str) -> String {
84 BASE64.encode(uuid)
85}
86
87/// Decode a Relay edge cursor back to a UUID string.
88///
89/// Returns `None` if the cursor is not valid base64 or not valid UTF-8.
90///
91/// # Example
92///
93/// ```
94/// use fraiseql_core::runtime::relay::{decode_uuid_cursor, encode_uuid_cursor};
95///
96/// let uuid = "550e8400-e29b-41d4-a716-446655440000";
97/// let cursor = encode_uuid_cursor(uuid);
98/// assert_eq!(decode_uuid_cursor(&cursor), Some(uuid.to_string()));
99/// assert_eq!(decode_uuid_cursor("not-valid-base64!!"), None);
100/// ```
101#[must_use]
102pub fn decode_uuid_cursor(cursor: &str) -> Option<String> {
103 let bytes = BASE64.decode(cursor).ok()?;
104 std::str::from_utf8(&bytes).ok().map(str::to_owned)
105}
106
107/// Encode a global Node ID as a Relay-compatible opaque ID.
108///
109/// The format is `base64("TypeName:uuid")`.
110///
111/// # Example
112///
113/// ```
114/// use fraiseql_core::runtime::relay::encode_node_id;
115///
116/// let id = encode_node_id("User", "550e8400-e29b-41d4-a716-446655440000");
117/// // id = base64("User:550e8400-e29b-41d4-a716-446655440000")
118/// assert!(!id.is_empty());
119/// ```
120#[must_use]
121pub fn encode_node_id(type_name: &str, uuid: &str) -> String {
122 BASE64.encode(format!("{type_name}:{uuid}"))
123}
124
125/// Decode a Relay global Node ID back to `(type_name, uuid)`.
126///
127/// Returns `None` if the ID is not valid base64 or does not have the
128/// expected `"TypeName:uuid"` format.
129///
130/// # Example
131///
132/// ```
133/// use fraiseql_core::runtime::relay::{decode_node_id, encode_node_id};
134///
135/// let id = encode_node_id("User", "550e8400-e29b-41d4-a716-446655440000");
136/// let decoded = decode_node_id(&id);
137/// assert_eq!(
138/// decoded,
139/// Some(("User".to_string(), "550e8400-e29b-41d4-a716-446655440000".to_string()))
140/// );
141/// ```
142#[must_use]
143pub fn decode_node_id(id: &str) -> Option<(String, String)> {
144 let bytes = BASE64.decode(id).ok()?;
145 let s = std::str::from_utf8(&bytes).ok()?;
146 let (type_name, uuid) = s.split_once(':')?;
147 if type_name.is_empty() || uuid.is_empty() {
148 return None;
149 }
150 Some((type_name.to_string(), uuid.to_string()))
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156
157 #[test]
158 fn test_edge_cursor_roundtrip() {
159 for pk in [0_i64, 1, 42, 999_999, i64::MAX] {
160 let cursor = encode_edge_cursor(pk);
161 assert_eq!(decode_edge_cursor(&cursor), Some(pk));
162 }
163 }
164
165 #[test]
166 fn test_edge_cursor_negative_pk() {
167 // Negative pks are unusual but still encodable.
168 let cursor = encode_edge_cursor(-1);
169 assert_eq!(decode_edge_cursor(&cursor), Some(-1));
170 }
171
172 #[test]
173 fn test_edge_cursor_i64_min_roundtrips() {
174 // Guards the sign-flip mutation: decode(encode(i64::MIN)) must equal i64::MIN.
175 let cursor = encode_edge_cursor(i64::MIN);
176 assert_eq!(
177 decode_edge_cursor(&cursor),
178 Some(i64::MIN),
179 "i64::MIN must roundtrip through encode/decode"
180 );
181 }
182
183 #[test]
184 fn test_edge_cursor_negative_max_roundtrips() {
185 // Guards -(i64::MAX): distinct from i64::MIN, covers the full negative range.
186 let cursor = encode_edge_cursor(-i64::MAX);
187 assert_eq!(decode_edge_cursor(&cursor), Some(-i64::MAX));
188 }
189
190 #[test]
191 fn test_edge_cursor_invalid() {
192 assert_eq!(decode_edge_cursor("!!!not-base64"), None);
193 assert_eq!(decode_edge_cursor(""), None);
194 // Valid base64 but not an integer.
195 let bad = BASE64.encode("not-a-number");
196 assert_eq!(decode_edge_cursor(&bad), None);
197 }
198
199 #[test]
200 fn test_node_id_roundtrip() {
201 let uuid = "550e8400-e29b-41d4-a716-446655440000";
202 let id = encode_node_id("User", uuid);
203 let decoded = decode_node_id(&id);
204 assert_eq!(decoded, Some(("User".to_string(), uuid.to_string())));
205 }
206
207 #[test]
208 fn test_node_id_various_types() {
209 for type_name in ["User", "BlogPost", "OrderItem"] {
210 let uuid = "00000000-0000-0000-0000-000000000001";
211 let id = encode_node_id(type_name, uuid);
212 let decoded = decode_node_id(&id);
213 assert_eq!(decoded.as_ref().map(|(t, _)| t.as_str()), Some(type_name));
214 assert_eq!(decoded.as_ref().map(|(_, u)| u.as_str()), Some(uuid));
215 }
216 }
217
218 #[test]
219 fn test_node_id_invalid() {
220 assert_eq!(decode_node_id("!!!not-base64"), None);
221 assert_eq!(decode_node_id(""), None);
222 // Valid base64 but no colon separator.
223 let no_colon = BASE64.encode("UserMissingColon");
224 assert_eq!(decode_node_id(&no_colon), None);
225 }
226
227 #[test]
228 fn test_edge_cursor_is_base64() {
229 let cursor = encode_edge_cursor(42);
230 // Verify it's valid base64 by decoding.
231 BASE64
232 .decode(&cursor)
233 .unwrap_or_else(|e| panic!("expected valid base64 edge cursor: {e}"));
234 }
235
236 #[test]
237 fn test_node_id_is_base64() {
238 let id = encode_node_id("User", "some-uuid");
239 BASE64
240 .decode(&id)
241 .unwrap_or_else(|e| panic!("expected valid base64 node ID: {e}"));
242 }
243}