Skip to main content

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}