Skip to main content

rain_metadata/meta/query/
mod.rs

1use std::sync::Arc;
2use reqwest::Client;
3use alloy::primitives::hex::decode;
4use serde::{Deserialize, Serialize};
5use graphql_client::{GraphQLQuery, Response, QueryBody};
6use super::super::error::Error;
7
8type Bytes = String;
9
10#[derive(GraphQLQuery)]
11#[graphql(
12    schema_path = "src/meta/query/schema.json",
13    query_path = "src/meta/query/meta.graphql",
14    response_derives = "Debug, Serialize, Deserialize"
15)]
16pub(super) struct MetaQuery;
17
18/// response data struct for a meta
19#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
20pub struct MetaResponse {
21    #[serde(with = "serde_bytes")]
22    pub bytes: Vec<u8>,
23}
24
25/// A graphql response carries an `errors` member independently of `data`, and
26/// serves an empty `errors` array to mean no errors at all.
27fn response_data<T>(response: Response<T>) -> Result<T, Error> {
28    if let Some(errors) = response.errors.filter(|errors| !errors.is_empty()) {
29        return Err(Error::SubgraphError(
30            errors
31                .iter()
32                .map(ToString::to_string)
33                .collect::<Vec<String>>()
34                .join("; "),
35        ));
36    }
37    response
38        .data
39        .ok_or_else(|| Error::SubgraphError("response carried neither data nor errors".to_string()))
40}
41
42/// A field of a record that was found: absent or unparsable makes the record
43/// corrupt, never absent.
44fn decode_field(field: &str, value: Option<&str>) -> Result<Vec<u8>, Error> {
45    match value {
46        Some(value) => decode(value).map_err(|e| Error::CorruptRecord(format!("{}: {}", field, e))),
47        None => Err(Error::CorruptRecord(format!("{} is missing", field))),
48    }
49}
50
51/// Process a response for a meta by resolving the record it holds, rejecting a
52/// subgraph that has no such record as `NoRecordFound` and one that cannot
53/// serve the record it has as `CorruptRecord`.
54/// This is because graphql responses are not rejected even if there was no record found for the request
55pub(super) async fn process_meta_query(
56    client: Arc<Client>,
57    request_body: &QueryBody<meta_query::Variables>,
58    url: &str,
59) -> Result<MetaResponse, Error> {
60    let raw_bytes = response_data(
61        client
62            .post(url)
63            .json(request_body)
64            .send()
65            .await
66            .map_err(Error::ReqwestError)?
67            .json::<Response<meta_query::ResponseData>>()
68            .await
69            .map_err(Error::ReqwestError)?,
70    )?
71    .meta
72    .ok_or(Error::NoRecordFound)?
73    .raw_bytes;
74
75    Ok(MetaResponse {
76        bytes: decode_field("rawBytes", Some(raw_bytes.as_str()))?,
77    })
78}
79
80#[cfg(all(test, not(target_family = "wasm")))]
81mod tests {
82    use super::*;
83
84    use httpmock::Method::POST;
85    use httpmock::MockServer;
86
87    fn request_body(hash: &str) -> QueryBody<meta_query::Variables> {
88        MetaQuery::build_query(meta_query::Variables {
89            hash: Some(hash.to_string()),
90        })
91    }
92
93    const HASH: &str = "0x1111111111111111111111111111111111111111111111111111111111111111";
94
95    /// A found meta resolves to exactly the hex-decoded rawBytes of the
96    /// response, fetched with a POST carrying the query body.
97    #[tokio::test]
98    async fn test_process_meta_query_success_exact_bytes() {
99        let server = MockServer::start_async().await;
100        server.mock(|when, then| {
101            when.method(POST)
102                .path("/")
103                .json_body_partial(format!(r#"{{"variables":{{"hash":"{}"}}}}"#, HASH));
104            then.status(200)
105                .header("content-type", "application/json")
106                .body(r#"{"data":{"meta":{"__typename":"RainMetaV1","rawBytes":"0xff0a89c674ee7874deadbeef"}}}"#);
107        });
108        let result = process_meta_query(
109            Arc::new(Client::new()),
110            &request_body(HASH),
111            &server.url("/"),
112        )
113        .await
114        .unwrap();
115        assert_eq!(
116            result.bytes,
117            vec![0xff, 0x0a, 0x89, 0xc6, 0x74, 0xee, 0x78, 0x74, 0xde, 0xad, 0xbe, 0xef]
118        );
119    }
120
121    /// A response with no data member and no errors member violates the
122    /// graphql response shape: that is the subgraph failing, not absence.
123    #[tokio::test]
124    async fn test_process_meta_query_missing_data_is_subgraph_error() {
125        let server = MockServer::start_async().await;
126        server.mock(|when, then| {
127            when.method(POST).path("/");
128            then.status(200)
129                .header("content-type", "application/json")
130                .body(r#"{"data":null}"#);
131        });
132        let result = process_meta_query(
133            Arc::new(Client::new()),
134            &request_body(HASH),
135            &server.url("/"),
136        )
137        .await;
138        match result {
139            Err(Error::SubgraphError(message)) => {
140                assert_eq!(message, "response carried neither data nor errors")
141            }
142            other => panic!("expected subgraph error, got {other:?}"),
143        }
144    }
145
146    /// Top level graphql errors are the subgraph rejecting the query, and are
147    /// reported as such rather than as absence, with every message carried.
148    #[tokio::test]
149    async fn test_process_meta_query_graphql_errors_is_subgraph_error() {
150        let server = MockServer::start_async().await;
151        server.mock(|when, then| {
152            when.method(POST).path("/");
153            then.status(200)
154                .header("content-type", "application/json")
155                .body(r#"{"data":null,"errors":[{"message":"first"},{"message":"second"}]}"#);
156        });
157        let result = process_meta_query(
158            Arc::new(Client::new()),
159            &request_body(HASH),
160            &server.url("/"),
161        )
162        .await;
163        match result {
164            Err(Error::SubgraphError(message)) => {
165                assert!(message.contains("first"), "{message}");
166                assert!(message.contains("second"), "{message}");
167            }
168            other => panic!("expected subgraph error, got {other:?}"),
169        }
170    }
171
172    /// An empty errors array is the graphql wire form for "no errors": it must
173    /// not turn a served record into a failure.
174    #[tokio::test]
175    async fn test_process_meta_query_empty_errors_array_is_success() {
176        let server = MockServer::start_async().await;
177        server.mock(|when, then| {
178            when.method(POST).path("/");
179            then.status(200)
180                .header("content-type", "application/json")
181                .body(
182                    r#"{"data":{"meta":{"__typename":"RainMetaV1","rawBytes":"0x0102"}},"errors":[]}"#,
183                );
184        });
185        let result = process_meta_query(
186            Arc::new(Client::new()),
187            &request_body(HASH),
188            &server.url("/"),
189        )
190        .await
191        .unwrap();
192        assert_eq!(result.bytes, vec![0x01, 0x02]);
193    }
194
195    /// A response with data but a null meta is "no record found".
196    #[tokio::test]
197    async fn test_process_meta_query_missing_meta_is_no_record_found() {
198        let server = MockServer::start_async().await;
199        server.mock(|when, then| {
200            when.method(POST).path("/");
201            then.status(200)
202                .header("content-type", "application/json")
203                .body(r#"{"data":{"meta":null}}"#);
204        });
205        let result = process_meta_query(
206            Arc::new(Client::new()),
207            &request_body(HASH),
208            &server.url("/"),
209        )
210        .await;
211        assert!(matches!(result, Err(Error::NoRecordFound)), "{result:?}");
212    }
213
214    /// rawBytes that do not hex-decode are a record the subgraph has but
215    /// cannot serve intact: corrupt, never absent and never bytes.
216    #[tokio::test]
217    async fn test_process_meta_query_bad_hex_is_corrupt_record() {
218        let server = MockServer::start_async().await;
219        server.mock(|when, then| {
220            when.method(POST).path("/");
221            then.status(200)
222                .header("content-type", "application/json")
223                .body(r#"{"data":{"meta":{"__typename":"RainMetaV1","rawBytes":"zz-not-hex"}}}"#);
224        });
225        let result = process_meta_query(
226            Arc::new(Client::new()),
227            &request_body(HASH),
228            &server.url("/"),
229        )
230        .await;
231        match result {
232            Err(Error::CorruptRecord(message)) => {
233                assert!(message.starts_with("rawBytes: "), "{message}")
234            }
235            other => panic!("expected corrupt record, got {other:?}"),
236        }
237    }
238
239    /// A transport failure surfaces as a reqwest error, not as a
240    /// no-record-found result.
241    #[tokio::test]
242    async fn test_process_meta_query_send_error_is_reqwest_error() {
243        // Bind and immediately release a local port so the request targets a
244        // closed port.
245        let port = {
246            let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
247            listener.local_addr().unwrap().port()
248        };
249        let url = format!("http://127.0.0.1:{port}/");
250        let result = process_meta_query(Arc::new(Client::new()), &request_body(HASH), &url).await;
251        assert!(matches!(result, Err(Error::ReqwestError(_))), "{result:?}");
252    }
253
254    /// A non-JSON response body surfaces as a reqwest decode error, not as
255    /// a no-record-found result.
256    #[tokio::test]
257    async fn test_process_meta_query_non_json_is_reqwest_error() {
258        let server = MockServer::start_async().await;
259        server.mock(|when, then| {
260            when.method(POST).path("/");
261            then.status(200)
262                .header("content-type", "text/plain")
263                .body("not json");
264        });
265        let result = process_meta_query(
266            Arc::new(Client::new()),
267            &request_body(HASH),
268            &server.url("/"),
269        )
270        .await;
271        assert!(matches!(result, Err(Error::ReqwestError(_))), "{result:?}");
272    }
273
274    /// process_meta_query separates a rejected query, a genuinely absent
275    /// record and a malformed one, and decodes a found record.
276    #[tokio::test]
277    async fn test_process_meta_query_paths() {
278        use httpmock::prelude::*;
279        let client = Arc::new(Client::builder().build().unwrap());
280        let body = MetaQuery::build_query(meta_query::Variables {
281            hash: Some("0xabc".to_string()),
282        });
283        let server = MockServer::start();
284
285        let no_data = server.mock(|when, then| {
286            when.method(POST).path("/nodata");
287            then.status(200)
288                .json_body(serde_json::json!({"errors": [{"message": "nope"}]}));
289        });
290        let result = process_meta_query(client.clone(), &body, &server.url("/nodata")).await;
291        assert!(matches!(result, Err(Error::SubgraphError(_))), "{result:?}");
292        no_data.assert();
293
294        let _no_meta = server.mock(|when, then| {
295            when.method(POST).path("/nometa");
296            then.status(200)
297                .json_body(serde_json::json!({"data": {"meta": null}}));
298        });
299        let result = process_meta_query(client.clone(), &body, &server.url("/nometa")).await;
300        assert!(matches!(result, Err(Error::NoRecordFound)), "{result:?}");
301
302        let _bad_hex = server.mock(|when, then| {
303            when.method(POST).path("/badhex");
304            then.status(200).json_body(serde_json::json!({
305                "data": {"meta": {"__typename": "RainMetaV1", "rawBytes": "0xzz"}}
306            }));
307        });
308        let result = process_meta_query(client.clone(), &body, &server.url("/badhex")).await;
309        assert!(matches!(result, Err(Error::CorruptRecord(_))), "{result:?}");
310
311        let _found = server.mock(|when, then| {
312            when.method(POST).path("/found");
313            then.status(200).json_body(serde_json::json!({
314                "data": {"meta": {"__typename": "RainMetaV1", "rawBytes": "0x0102"}}
315            }));
316        });
317        let result = process_meta_query(client.clone(), &body, &server.url("/found"))
318            .await
319            .unwrap();
320        assert_eq!(result.bytes, vec![0x01, 0x02]);
321    }
322}