Skip to main content

apollo_router/services/
subgraph.rs

1#![allow(missing_docs)] // FIXME
2
3use std::collections::HashSet;
4use std::fmt::Display;
5use std::pin::Pin;
6use std::sync::Arc;
7use std::time::Duration;
8
9use apollo_compiler::validation::Valid;
10use http::StatusCode;
11use http::Version;
12use itertools::Itertools;
13use multimap::MultiMap;
14use serde::Deserialize;
15use serde::Serialize;
16use serde_json_bytes::ByteString;
17use serde_json_bytes::Map as JsonMap;
18use serde_json_bytes::Value;
19use sha2::Digest;
20use sha2::Sha256;
21use static_assertions::assert_impl_all;
22use tokio::sync::broadcast;
23use tokio::sync::mpsc;
24use tokio_stream::Stream;
25use tower::BoxError;
26
27use crate::Context;
28use crate::batching::BatchQuery;
29use crate::error::Error;
30use crate::graphql;
31use crate::http_ext::TryIntoHeaderName;
32use crate::http_ext::TryIntoHeaderValue;
33use crate::http_ext::header_map;
34use crate::json_ext::Object;
35use crate::json_ext::Path;
36use crate::plugins::authentication::APOLLO_AUTHENTICATION_JWT_CLAIMS;
37use crate::plugins::authentication::subgraph::SigningParamsConfig;
38use crate::plugins::authorization::CacheKeyMetadata;
39use crate::plugins::response_cache::cache_control::CacheControl;
40use crate::query_planner::fetch::OperationKind;
41use crate::spec::QueryHash;
42
43pub type BoxService = tower::util::BoxService<Request, Response, BoxError>;
44pub type BoxCloneService = tower::util::BoxCloneService<Request, Response, BoxError>;
45pub type ServiceResult = Result<Response, BoxError>;
46pub(crate) type BoxGqlStream = Pin<Box<dyn Stream<Item = graphql::Response> + Send + Sync>>;
47/// unique id for a subgraph request and the related response
48#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
49pub struct SubgraphRequestId(pub String);
50
51impl Display for SubgraphRequestId {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        write!(f, "{}", self.0)
54    }
55}
56
57assert_impl_all!(Request: Send);
58#[non_exhaustive]
59pub struct Request {
60    /// Original request to the Router.
61    pub supergraph_request: Arc<http::Request<graphql::Request>>,
62
63    pub subgraph_request: http::Request<graphql::Request>,
64
65    pub operation_kind: OperationKind,
66
67    pub context: Context,
68
69    /// Name of the subgraph
70    pub(crate) subgraph_name: String,
71    /// Channel to send the subscription stream to listen on events coming from subgraph in a task
72    pub(crate) subscription_stream: Option<mpsc::Sender<BoxGqlStream>>,
73    /// Channel triggered when the client connection has been dropped
74    pub(crate) connection_closed_signal: Option<broadcast::Receiver<()>>,
75
76    pub(crate) query_hash: Arc<QueryHash>,
77
78    // authorization metadata for this request
79    pub(crate) authorization: Arc<CacheKeyMetadata>,
80
81    pub(crate) executable_document: Option<Arc<Valid<apollo_compiler::ExecutableDocument>>>,
82
83    /// unique id for this request
84    pub(crate) id: SubgraphRequestId,
85}
86
87#[buildstructor::buildstructor]
88impl Request {
89    /// This is the constructor (or builder) to use when constructing a real Request.
90    ///
91    /// Required parameters are required in non-testing code to create a Request.
92    #[builder(visibility = "pub")]
93    fn new(
94        supergraph_request: Arc<http::Request<graphql::Request>>,
95        subgraph_request: http::Request<graphql::Request>,
96        operation_kind: OperationKind,
97        context: Context,
98        subscription_stream: Option<mpsc::Sender<BoxGqlStream>>,
99        subgraph_name: String,
100        connection_closed_signal: Option<broadcast::Receiver<()>>,
101        executable_document: Option<Arc<Valid<apollo_compiler::ExecutableDocument>>>,
102    ) -> Request {
103        Self {
104            supergraph_request,
105            subgraph_request,
106            operation_kind,
107            context,
108            subgraph_name,
109            subscription_stream,
110            connection_closed_signal,
111            // It's NOT GREAT! to have an empty hash value here.
112            // This value is populated based on the subgraph query hash in the query planner code.
113            // At the time of writing it's in `crate::query_planner::fetch::FetchNode::fetch_node`.
114            query_hash: QueryHash::default().into(),
115            authorization: Default::default(),
116            executable_document,
117            id: SubgraphRequestId::new(),
118        }
119    }
120
121    /// This is the constructor (or builder) to use when constructing a "fake" Request.
122    ///
123    /// This does not enforce the provision of the data that is required for a fully functional
124    /// Request. It's usually enough for testing, when a fully consructed Request is
125    /// difficult to construct and not required for the pusposes of the test.
126    #[builder(visibility = "pub")]
127    fn fake_new(
128        supergraph_request: Option<Arc<http::Request<graphql::Request>>>,
129        subgraph_request: Option<http::Request<graphql::Request>>,
130        operation_kind: Option<OperationKind>,
131        context: Option<Context>,
132        subscription_stream: Option<mpsc::Sender<BoxGqlStream>>,
133        subgraph_name: Option<String>,
134        connection_closed_signal: Option<broadcast::Receiver<()>>,
135    ) -> Request {
136        Request::new(
137            supergraph_request.unwrap_or_default(),
138            subgraph_request.unwrap_or_default(),
139            operation_kind.unwrap_or(OperationKind::Query),
140            context.unwrap_or_default(),
141            subscription_stream,
142            subgraph_name.unwrap_or_default(),
143            connection_closed_signal,
144            None,
145        )
146    }
147
148    pub(crate) fn is_part_of_batch(&self) -> bool {
149        self.context
150            .extensions()
151            .with_lock(|lock| lock.contains_key::<BatchQuery>())
152    }
153
154    pub(crate) fn subgraph_operation_name(&self) -> Option<&str> {
155        self.subgraph_request.body().operation_name.as_deref()
156    }
157
158    pub(crate) fn root_operation_fields(&self) -> Vec<String> {
159        self.executable_document
160            .as_ref()
161            .and_then(|executable_document| {
162                let operation_name = self.subgraph_operation_name();
163                Some(
164                    executable_document
165                        .operations
166                        .get(operation_name)
167                        .ok()?
168                        .root_fields(executable_document)
169                        .map(|f| f.name.to_string())
170                        .collect(),
171                )
172            })
173            .unwrap_or_default()
174    }
175}
176
177impl Clone for Request {
178    fn clone(&self) -> Self {
179        // http::Request is not clonable so we have to rebuild a new one
180        let mut builder = http::Request::builder()
181            .method(self.subgraph_request.method())
182            .version(self.subgraph_request.version())
183            .uri(self.subgraph_request.uri());
184
185        {
186            let headers = builder.headers_mut().unwrap();
187            headers.extend(
188                self.subgraph_request
189                    .headers()
190                    .iter()
191                    .map(|(name, value)| (name.clone(), value.clone())),
192            );
193        }
194        let mut subgraph_request = builder.body(self.subgraph_request.body().clone()).unwrap();
195        // Copy only Arc<SigningParamsConfig> so APQ probe requests can be signed.
196        //
197        // We deliberately avoid copying all extensions: some types (e.g. MultipartFormData
198        // in the file-uploads plugin) hold shared stream state that must not be shared with
199        // the APQ probe clone — draining the probe would exhaust the original on retry.
200        //
201        // If a new extension type needs to survive SubgraphRequest clones, add it here.
202        if let Some(signing_params) = self
203            .subgraph_request
204            .extensions()
205            .get::<Arc<SigningParamsConfig>>()
206            .cloned()
207        {
208            subgraph_request.extensions_mut().insert(signing_params);
209        }
210
211        Self {
212            supergraph_request: self.supergraph_request.clone(),
213            subgraph_request,
214            operation_kind: self.operation_kind,
215            context: self.context.clone(),
216            subgraph_name: self.subgraph_name.clone(),
217            subscription_stream: self.subscription_stream.clone(),
218            connection_closed_signal: self
219                .connection_closed_signal
220                .as_ref()
221                .map(|s| s.resubscribe()),
222            query_hash: self.query_hash.clone(),
223            authorization: self.authorization.clone(),
224            executable_document: self.executable_document.clone(),
225            id: self.id.clone(),
226        }
227    }
228}
229
230impl SubgraphRequestId {
231    pub fn new() -> Self {
232        SubgraphRequestId(
233            uuid::Uuid::new_v4()
234                .as_hyphenated()
235                .encode_lower(&mut uuid::Uuid::encode_buffer())
236                .to_string(),
237        )
238    }
239}
240
241impl std::ops::Deref for SubgraphRequestId {
242    type Target = str;
243
244    fn deref(&self) -> &str {
245        &self.0
246    }
247}
248
249impl Default for SubgraphRequestId {
250    fn default() -> Self {
251        Self::new()
252    }
253}
254
255assert_impl_all!(Response: Send);
256#[derive(Debug)]
257#[non_exhaustive]
258pub struct Response {
259    pub response: http::Response<graphql::Response>,
260    /// Name of the subgraph
261    pub(crate) subgraph_name: String,
262    pub context: Context,
263    /// unique id matching the corresponding field in the request
264    pub(crate) id: SubgraphRequestId,
265}
266
267#[buildstructor::buildstructor]
268impl Response {
269    /// This is the constructor to use when constructing a real Response..
270    ///
271    /// In this case, you already have a valid response and just wish to associate it with a context
272    /// and create a Response.
273    pub(crate) fn new_from_response(
274        response: http::Response<graphql::Response>,
275        context: Context,
276        subgraph_name: String,
277        id: SubgraphRequestId,
278    ) -> Self {
279        Self {
280            response,
281            context,
282            subgraph_name,
283            id,
284        }
285    }
286
287    /// This is the constructor (or builder) to use when constructing a real Response.
288    ///
289    /// The parameters are not optional, because in a live situation all of these properties must be
290    /// set and be correct to create a Response.
291    #[builder(visibility = "pub")]
292    fn new(
293        label: Option<String>,
294        data: Option<Value>,
295        path: Option<Path>,
296        errors: Vec<Error>,
297        extensions: Object,
298        status_code: Option<StatusCode>,
299        context: Context,
300        headers: Option<http::HeaderMap<http::HeaderValue>>,
301        subgraph_name: String,
302        id: Option<SubgraphRequestId>,
303    ) -> Self {
304        // Build a response
305        let res = graphql::Response::builder()
306            .and_label(label)
307            .data(data.unwrap_or_default())
308            .and_path(path)
309            .errors(errors)
310            .extensions(extensions)
311            .build();
312
313        // Build an http Response
314        let mut response = http::Response::builder()
315            .status(status_code.unwrap_or(StatusCode::OK))
316            .body(res)
317            .expect("Response is serializable; qed");
318
319        *response.headers_mut() = headers.unwrap_or_default();
320
321        // Warning: the id argument for this builder is an Option to make that a non breaking change
322        // but this means that if a subgraph response is created explicitly without an id, it will
323        // be generated here and not match the id from the subgraph request
324        let id = id.unwrap_or_default();
325
326        Self {
327            response,
328            context,
329            subgraph_name,
330            id,
331        }
332    }
333
334    /// This is the constructor (or builder) to use when constructing a "fake" Response.
335    ///
336    /// This does not enforce the provision of the data that is required for a fully functional
337    /// Response. It's usually enough for testing, when a fully constructed Response is
338    /// difficult to construct and not required for the purposes of the test.
339    #[builder(visibility = "pub")]
340    fn fake_new(
341        label: Option<String>,
342        data: Option<Value>,
343        path: Option<Path>,
344        errors: Vec<Error>,
345        // Skip the `Object` type alias in order to use buildstructor’s map special-casing
346        extensions: JsonMap<ByteString, Value>,
347        status_code: Option<StatusCode>,
348        context: Option<Context>,
349        headers: Option<http::HeaderMap<http::HeaderValue>>,
350        subgraph_name: Option<String>,
351        id: Option<SubgraphRequestId>,
352    ) -> Self {
353        Self::new(
354            label,
355            data,
356            path,
357            errors,
358            extensions,
359            status_code,
360            context.unwrap_or_default(),
361            headers,
362            subgraph_name.unwrap_or_default(),
363            id,
364        )
365    }
366
367    /// This is the constructor (or builder) to use when constructing a "fake" Response.
368    /// It differs from the existing fake_new because it allows easier passing of headers. However we can't change the original without breaking the public APIs.
369    ///
370    /// This does not enforce the provision of the data that is required for a fully functional
371    /// Response. It's usually enough for testing, when a fully constructed Response is
372    /// difficult to construct and not required for the purposes of the test.
373    #[builder(visibility = "pub")]
374    fn fake2_new(
375        label: Option<String>,
376        data: Option<Value>,
377        path: Option<Path>,
378        errors: Vec<Error>,
379        // Skip the `Object` type alias in order to use buildstructor’s map special-casing
380        extensions: JsonMap<ByteString, Value>,
381        status_code: Option<StatusCode>,
382        context: Option<Context>,
383        headers: MultiMap<TryIntoHeaderName, TryIntoHeaderValue>,
384        subgraph_name: Option<String>,
385        id: Option<SubgraphRequestId>,
386    ) -> Result<Response, BoxError> {
387        Ok(Self::new(
388            label,
389            data,
390            path,
391            errors,
392            extensions,
393            status_code,
394            context.unwrap_or_default(),
395            Some(header_map(headers)?),
396            subgraph_name.unwrap_or_default(),
397            id,
398        ))
399    }
400
401    /// This is the constructor (or builder) to use when constructing a Response that represents a global error.
402    /// It has no path and no response data.
403    /// This is useful for things such as authentication errors.
404    #[builder(visibility = "pub")]
405    fn error_new(
406        errors: Vec<Error>,
407        status_code: Option<StatusCode>,
408        context: Context,
409        subgraph_name: String,
410        id: Option<SubgraphRequestId>,
411    ) -> Self {
412        Self::new(
413            Default::default(),
414            Default::default(),
415            Default::default(),
416            errors,
417            Default::default(),
418            status_code,
419            context,
420            Default::default(),
421            subgraph_name,
422            id,
423        )
424    }
425
426    pub(crate) fn subgraph_cache_control(
427        &self,
428        default_ttl: Option<Duration>,
429    ) -> Result<CacheControl, BoxError> {
430        Ok(CacheControl::try_from(self.response.headers())?.with_default_ttl(default_ttl))
431    }
432
433    pub(crate) fn get_from_extensions(&self, key: &str) -> Option<&Value> {
434        self.response.body().extensions.get(key)
435    }
436}
437
438impl Request {
439    pub(crate) fn to_sha256(
440        &self,
441        ignored_headers: &HashSet<String>,
442        ignore_auth_context: bool,
443    ) -> String {
444        let mut hasher = Sha256::new();
445        let http_req = &self.subgraph_request;
446        hasher.update(http_req.method().as_str().as_bytes());
447
448        // To not allocate
449        let version = match http_req.version() {
450            Version::HTTP_09 => "HTTP/0.9",
451            Version::HTTP_10 => "HTTP/1.0",
452            Version::HTTP_11 => "HTTP/1.1",
453            Version::HTTP_2 => "HTTP/2.0",
454            Version::HTTP_3 => "HTTP/3.0",
455            _ => "unknown",
456        };
457        hasher.update(version.as_bytes());
458        let uri = http_req.uri();
459        if let Some(scheme) = uri.scheme() {
460            hasher.update(scheme.as_str().as_bytes());
461        }
462        if let Some(authority) = uri.authority() {
463            hasher.update(authority.as_str().as_bytes());
464        }
465        if let Some(query) = uri.query() {
466            hasher.update(query.as_bytes());
467        }
468
469        // HeaderMap iteration order is not stable across requests, so sort
470        // (name, value) pairs before feeding them to the hasher. Without this,
471        // two logically identical requests can produce different hashes and
472        // miss the dedup cache.
473        //
474        // A NUL byte is fed between every name/value/pair so concatenated
475        // pairs cannot collide (e.g. `[("x","y"), ("xy","")]` vs
476        // `[("x","yxy")]` would otherwise both feed the hasher "xyxy"), and
477        // raw `as_bytes()` is used so non-ASCII header values are not
478        // collapsed via lossy `to_str()`.
479        // Each section below is preceded by a distinct two-byte tag so that an
480        // empty section followed by a populated one cannot produce the same byte
481        // stream as the populated section followed by an empty one. Without these
482        // tags `{variables: {"k": "1"}, extensions: {}}` and
483        // `{variables: {}, extensions: {"k": "1"}}` hash identically, causing
484        // subgraph dedup-cache collisions.
485        let mut headers: Vec<(&[u8], &[u8])> = Vec::with_capacity(http_req.headers().len());
486        headers.extend(
487            http_req
488                .headers()
489                .iter()
490                .filter(|(name, _)| !ignored_headers.contains(name.as_str()))
491                .map(|(name, value)| (name.as_str().as_bytes(), value.as_bytes())),
492        );
493        hasher.update(b"\0H");
494        sort_and_hash(&mut hasher, headers);
495
496        if !ignore_auth_context
497            && let Some(claim) = self
498                .context
499                .get_json_value(APOLLO_AUTHENTICATION_JWT_CLAIMS)
500        {
501            hasher.update(b"\0C");
502            hasher.update(format!("{claim:?}").as_bytes());
503        }
504        let body = http_req.body();
505        if let Some(operation_name) = &body.operation_name {
506            hasher.update(b"\0O");
507            hasher.update(operation_name.as_bytes());
508        }
509        if let Some(query) = &body.query {
510            hasher.update(b"\0Q");
511            hasher.update(query.as_bytes());
512        }
513        // Apply the same sort + NUL-delimiter pattern as the headers above so
514        // logically identical bodies hash identically regardless of insertion order,
515        // and concatenated (name, value) pairs cannot collide across distinct logical
516        // inputs.
517
518        hasher.update(b"\0V");
519        sort_and_hash(
520            &mut hasher,
521            body.variables
522                .iter()
523                .map(|(k, v)| (k.inner(), v.to_bytes())),
524        );
525        hasher.update(b"\0E");
526        sort_and_hash(
527            &mut hasher,
528            body.extensions
529                .iter()
530                .map(|(k, v)| (k.inner(), v.to_bytes())),
531        );
532
533        hex::encode(hasher.finalize())
534    }
535}
536
537/// Stabilizes the ordering of headers, bodies, variables, and so on before hashing to make
538/// same-but-differently ordered headers, bodies, variables, etc, produce the same hash
539fn sort_and_hash(
540    hasher: &mut Sha256,
541    pairs: impl IntoIterator<Item = (impl AsRef<[u8]>, impl AsRef<[u8]>)>,
542) {
543    let sorted = pairs
544        .into_iter()
545        .sorted_unstable_by(|a, b| a.0.as_ref().cmp(b.0.as_ref()));
546    for (k, v) in sorted {
547        hasher.update(k.as_ref());
548        hasher.update([0]);
549        hasher.update(v.as_ref());
550        hasher.update([0]);
551    }
552}
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557
558    #[test]
559    fn test_subgraph_request_hash() {
560        let subgraph_req_1 = Request::fake_builder()
561            .subgraph_request(
562                http::Request::builder()
563                    .header("public_header", "value")
564                    .header("auth", "my_token")
565                    .body(graphql::Request::default())
566                    .unwrap(),
567            )
568            .build();
569        let subgraph_req_2 = Request::fake_builder()
570            .subgraph_request(
571                http::Request::builder()
572                    .header("public_header", "value_bis")
573                    .header("auth", "my_token")
574                    .body(graphql::Request::default())
575                    .unwrap(),
576            )
577            .build();
578        let mut ignored_headers = HashSet::new();
579        ignored_headers.insert("public_header".to_string());
580        assert_eq!(
581            subgraph_req_1.to_sha256(&ignored_headers, false),
582            subgraph_req_2.to_sha256(&ignored_headers, false)
583        );
584
585        let subgraph_req_1 = Request::fake_builder()
586            .subgraph_request(
587                http::Request::builder()
588                    .header("public_header", "value")
589                    .header("auth", "my_token")
590                    .body(graphql::Request::default())
591                    .unwrap(),
592            )
593            .build();
594        let subgraph_req_2 = Request::fake_builder()
595            .subgraph_request(
596                http::Request::builder()
597                    .header("public_header", "value_bis")
598                    .header("auth", "my_token")
599                    .body(graphql::Request::default())
600                    .unwrap(),
601            )
602            .build();
603        let ignored_headers = HashSet::new();
604        assert_ne!(
605            subgraph_req_1.to_sha256(&ignored_headers, false),
606            subgraph_req_2.to_sha256(&ignored_headers, false)
607        );
608    }
609
610    #[test]
611    fn test_subgraph_request_hash_ignore_auth_context() {
612        use serde_json_bytes::json;
613
614        // Build two requests with different JWT claims in context.
615        let req_with_claims_a = Request::fake_builder()
616            .subgraph_request(
617                http::Request::builder()
618                    .body(graphql::Request::default())
619                    .unwrap(),
620            )
621            .build();
622        req_with_claims_a
623            .context
624            .insert(APOLLO_AUTHENTICATION_JWT_CLAIMS, json!({"sub": "user-a"}))
625            .expect("insert JWT claims");
626
627        let req_with_claims_b = Request::fake_builder()
628            .subgraph_request(
629                http::Request::builder()
630                    .body(graphql::Request::default())
631                    .unwrap(),
632            )
633            .build();
634        req_with_claims_b
635            .context
636            .insert(APOLLO_AUTHENTICATION_JWT_CLAIMS, json!({"sub": "user-b"}))
637            .expect("insert JWT claims");
638
639        let ignored_headers = HashSet::new();
640
641        // Different claims → different hashes when auth context is included.
642        assert_ne!(
643            req_with_claims_a.to_sha256(&ignored_headers, false),
644            req_with_claims_b.to_sha256(&ignored_headers, false),
645            "requests with different JWT claims must hash differently by default"
646        );
647
648        // Same hash when auth context is ignored.
649        assert_eq!(
650            req_with_claims_a.to_sha256(&ignored_headers, true),
651            req_with_claims_b.to_sha256(&ignored_headers, true),
652            "requests with different JWT claims must hash identically when ignore_auth_context is true"
653        );
654    }
655
656    #[test]
657    fn test_clone_does_not_copy_arbitrary_subgraph_request_extensions() {
658        // The Clone impl copies only specific extension types needed for APQ retries
659        // (Arc<SigningParamsConfig> for SigV4 — see authentication/subgraph.rs for the
660        // positive test). Arbitrary types must NOT be copied: some extensions
661        // (e.g. MultipartFormData in file uploads) hold shared stream state, and copying
662        // them would cause the APQ probe clone to exhaust the stream before the retry.
663        #[derive(Clone, PartialEq, Debug)]
664        struct ShouldNotSurviveClone(u32);
665
666        let mut req = Request::fake_builder()
667            .subgraph_request(
668                http::Request::builder()
669                    .body(graphql::Request::default())
670                    .unwrap(),
671            )
672            .build();
673        req.subgraph_request
674            .extensions_mut()
675            .insert(ShouldNotSurviveClone(42));
676
677        let cloned = req.clone();
678        assert!(
679            cloned
680                .subgraph_request
681                .extensions()
682                .get::<ShouldNotSurviveClone>()
683                .is_none(),
684            "arbitrary extension types must not be copied when SubgraphRequest is cloned"
685        );
686    }
687
688    #[test]
689    fn test_subgraph_request_hash_no_delimiter_collision() {
690        // Without delimiters between concatenated (name, value) bytes, these
691        // two requests would feed the hasher the same `"xyxy"` byte sequence
692        // (sorted: `("x","y"),("xy","")` and `("x","yxy")` respectively).
693        let req_two_headers = Request::fake_builder()
694            .subgraph_request(
695                http::Request::builder()
696                    .header("x", "y")
697                    .header("xy", "")
698                    .body(graphql::Request::default())
699                    .unwrap(),
700            )
701            .build();
702        let req_one_header = Request::fake_builder()
703            .subgraph_request(
704                http::Request::builder()
705                    .header("x", "yxy")
706                    .body(graphql::Request::default())
707                    .unwrap(),
708            )
709            .build();
710        let ignored_headers = HashSet::new();
711        assert_ne!(
712            req_two_headers.to_sha256(&ignored_headers, false),
713            req_one_header.to_sha256(&ignored_headers, false),
714            "header pairs must be delimited so concatenations cannot collide"
715        );
716    }
717
718    #[test]
719    fn test_subgraph_request_hash_non_ascii_value_distinguishable() {
720        // Pre-fix, both non-ASCII values collapsed to the literal "ERROR" via
721        // `to_str().unwrap_or(...)`, producing identical hashes. Post-fix the
722        // raw bytes are hashed and the two requests are distinguishable.
723        let req_a = Request::fake_builder()
724            .subgraph_request(
725                http::Request::builder()
726                    .header(
727                        "x-custom",
728                        http::HeaderValue::from_bytes(&[0xC3, 0xA9]).unwrap(),
729                    )
730                    .body(graphql::Request::default())
731                    .unwrap(),
732            )
733            .build();
734        let req_b = Request::fake_builder()
735            .subgraph_request(
736                http::Request::builder()
737                    .header(
738                        "x-custom",
739                        http::HeaderValue::from_bytes(&[0xC3, 0xB1]).unwrap(),
740                    )
741                    .body(graphql::Request::default())
742                    .unwrap(),
743            )
744            .build();
745        let ignored_headers = HashSet::new();
746        assert_ne!(
747            req_a.to_sha256(&ignored_headers, false),
748            req_b.to_sha256(&ignored_headers, false),
749            "non-ASCII header values must not be collapsed to a single sentinel"
750        );
751    }
752
753    #[test]
754    fn test_subgraph_request_hash_variables_order_independence() {
755        use serde_json_bytes::json;
756
757        let mut vars_a = JsonMap::new();
758        vars_a.insert("a", json!(1));
759        vars_a.insert("b", json!(2));
760        vars_a.insert("c", json!(3));
761        let mut vars_b = JsonMap::new();
762        vars_b.insert("c", json!(3));
763        vars_b.insert("a", json!(1));
764        vars_b.insert("b", json!(2));
765
766        let req_a = Request::fake_builder()
767            .subgraph_request(
768                http::Request::builder()
769                    .body(graphql::Request::builder().variables(vars_a).build())
770                    .unwrap(),
771            )
772            .build();
773        let req_b = Request::fake_builder()
774            .subgraph_request(
775                http::Request::builder()
776                    .body(graphql::Request::builder().variables(vars_b).build())
777                    .unwrap(),
778            )
779            .build();
780        let ignored_headers = HashSet::new();
781        assert_eq!(
782            req_a.to_sha256(&ignored_headers, false),
783            req_b.to_sha256(&ignored_headers, false),
784            "two requests with the same variables in different insertion orders must hash identically"
785        );
786    }
787
788    #[test]
789    fn test_subgraph_request_hash_variables_no_delimiter_collision() {
790        use serde_json_bytes::json;
791
792        // Without delimiters between concatenated (name, value) bytes, these
793        // two requests would feed the hasher the same `"key1value2null"` byte
794        // sequence: `{"key": 1, "value2": null}` flattens to "key" + "1" +
795        // "value2" + "null", and `{"key1value2": null}` flattens to
796        // "key1value2" + "null".
797        let mut vars_two = JsonMap::new();
798        vars_two.insert("key", json!(1));
799        vars_two.insert("value2", json!(null));
800        let mut vars_one = JsonMap::new();
801        vars_one.insert("key1value2", json!(null));
802
803        let req_two = Request::fake_builder()
804            .subgraph_request(
805                http::Request::builder()
806                    .body(graphql::Request::builder().variables(vars_two).build())
807                    .unwrap(),
808            )
809            .build();
810        let req_one = Request::fake_builder()
811            .subgraph_request(
812                http::Request::builder()
813                    .body(graphql::Request::builder().variables(vars_one).build())
814                    .unwrap(),
815            )
816            .build();
817        let ignored_headers = HashSet::new();
818        assert_ne!(
819            req_two.to_sha256(&ignored_headers, false),
820            req_one.to_sha256(&ignored_headers, false),
821            "variable pairs must be delimited so concatenations cannot collide"
822        );
823    }
824
825    #[test]
826    fn test_subgraph_request_hash_no_cross_section_collision_variables_vs_extensions() {
827        use serde_json_bytes::json;
828
829        // Without per-section tags, these two requests would feed the hasher
830        // the same byte stream (`"k\01\0"`), because `sort_and_hash` emits no
831        // bytes for an empty iterator and no terminator for the section as a
832        // whole. A swap between variables and extensions would then produce
833        // identical hashes — letting the subgraph dedup cache return request
834        // A's response to request B.
835        let mut vars = JsonMap::new();
836        vars.insert("k", json!(1));
837        let mut exts = JsonMap::new();
838        exts.insert("k", json!(1));
839
840        let req_vars_only = Request::fake_builder()
841            .subgraph_request(
842                http::Request::builder()
843                    .body(graphql::Request::builder().variables(vars).build())
844                    .unwrap(),
845            )
846            .build();
847        let req_exts_only = Request::fake_builder()
848            .subgraph_request(
849                http::Request::builder()
850                    .body(graphql::Request::builder().extensions(exts).build())
851                    .unwrap(),
852            )
853            .build();
854        let ignored_headers = HashSet::new();
855        assert_ne!(
856            req_vars_only.to_sha256(&ignored_headers, false),
857            req_exts_only.to_sha256(&ignored_headers, false),
858            "the variables and extensions sections must be domain-separated so identical \
859             entries in different sections cannot collide"
860        );
861    }
862
863    #[test]
864    fn test_subgraph_request_hash_no_cross_section_collision_query_vs_operation_name() {
865        // Without per-section tags, `operation_name + query` is just concatenated
866        // bytes, so `operation_name: "AB", query: "CD"` and
867        // `operation_name: "ABC", query: "D"` would hash identically.
868        let req_a = Request::fake_builder()
869            .subgraph_request(
870                http::Request::builder()
871                    .body(
872                        graphql::Request::builder()
873                            .operation_name("AB")
874                            .query("CD")
875                            .build(),
876                    )
877                    .unwrap(),
878            )
879            .build();
880        let req_b = Request::fake_builder()
881            .subgraph_request(
882                http::Request::builder()
883                    .body(
884                        graphql::Request::builder()
885                            .operation_name("ABC")
886                            .query("D")
887                            .build(),
888                    )
889                    .unwrap(),
890            )
891            .build();
892        let ignored_headers = HashSet::new();
893        assert_ne!(
894            req_a.to_sha256(&ignored_headers, false),
895            req_b.to_sha256(&ignored_headers, false),
896            "operation_name and query must be domain-separated so concatenations cannot collide"
897        );
898    }
899
900    #[test]
901    fn test_subgraph_request_hash_extensions_order_independence() {
902        use serde_json_bytes::json;
903
904        let mut ext_a = JsonMap::new();
905        ext_a.insert("alpha", json!("x"));
906        ext_a.insert("beta", json!("y"));
907        let mut ext_b = JsonMap::new();
908        ext_b.insert("beta", json!("y"));
909        ext_b.insert("alpha", json!("x"));
910
911        let req_a = Request::fake_builder()
912            .subgraph_request(
913                http::Request::builder()
914                    .body(graphql::Request::builder().extensions(ext_a).build())
915                    .unwrap(),
916            )
917            .build();
918        let req_b = Request::fake_builder()
919            .subgraph_request(
920                http::Request::builder()
921                    .body(graphql::Request::builder().extensions(ext_b).build())
922                    .unwrap(),
923            )
924            .build();
925        let ignored_headers = HashSet::new();
926        assert_eq!(
927            req_a.to_sha256(&ignored_headers, false),
928            req_b.to_sha256(&ignored_headers, false),
929            "two requests with the same extensions in different insertion orders must hash identically"
930        );
931    }
932
933    #[test]
934    fn test_subgraph_request_hash_header_order_independence() {
935        let req_a = Request::fake_builder()
936            .subgraph_request(
937                http::Request::builder()
938                    .header("x-a", "1")
939                    .header("x-b", "2")
940                    .header("x-c", "3")
941                    .body(graphql::Request::default())
942                    .unwrap(),
943            )
944            .build();
945        let req_b = Request::fake_builder()
946            .subgraph_request(
947                http::Request::builder()
948                    .header("x-c", "3")
949                    .header("x-a", "1")
950                    .header("x-b", "2")
951                    .body(graphql::Request::default())
952                    .unwrap(),
953            )
954            .build();
955        let ignored_headers = HashSet::new();
956        assert_eq!(
957            req_a.to_sha256(&ignored_headers, false),
958            req_b.to_sha256(&ignored_headers, false),
959            "two requests with the same headers in different insertion orders must hash identically"
960        );
961    }
962}