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