use std::fmt::Write as _;
use serde_json::{Map, Value};
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GraphqlIdentity {
pub operation_name: Option<String>,
pub query_digest: String,
pub variables_digest: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RequestIdentity {
pub method: String,
pub path: String,
pub content_type: String,
pub body_digest: Option<String>,
pub graphql: Option<GraphqlIdentity>,
}
impl RequestIdentity {
#[must_use]
pub fn key(&self) -> String {
let mut key = format!("{} {}", self.method, self.path);
if !self.content_type.is_empty() {
key.push(' ');
key.push_str(&self.content_type);
}
if let Some(graphql) = &self.graphql {
key.push_str(" gql:");
key.push_str(graphql.operation_name.as_deref().unwrap_or("-"));
key.push_str(" q:");
key.push_str(&graphql.query_digest);
key.push_str(" v:");
key.push_str(&graphql.variables_digest);
} else if let Some(digest) = &self.body_digest {
key.push_str(" body:");
key.push_str(digest);
}
key
}
}
#[must_use]
pub fn identify_request(
method: &str,
url: &str,
content_type: &str,
body: Option<&[u8]>,
) -> RequestIdentity {
let method = method.trim().to_ascii_uppercase();
let path = request_path(url);
let content_type = media_type(content_type);
let parsed_json = body.and_then(|bytes| serde_json::from_slice::<Value>(bytes).ok());
if looks_like_graphql(&path, &content_type, parsed_json.as_ref(), body)
&& let Some(graphql) = graphql_identity(&content_type, body, parsed_json.as_ref())
{
return RequestIdentity {
method,
path,
content_type,
body_digest: None,
graphql: Some(graphql),
};
}
let body_digest = body
.filter(|bytes| !bytes.is_empty())
.map(|bytes| match &parsed_json {
Some(value) => canonical_json_digest(value),
None => sha256_hex(bytes),
});
RequestIdentity {
method,
path,
content_type,
body_digest,
graphql: None,
}
}
#[must_use]
pub fn request_path(url: &str) -> String {
let Some((_, after_scheme)) = url.split_once("://") else {
return url.to_owned();
};
after_scheme
.find('/')
.map_or_else(|| "/".into(), |index| after_scheme[index..].to_owned())
}
#[must_use]
pub fn canonical_json_digest(value: &Value) -> String {
sha256_hex(canonical_json(value).to_string().as_bytes())
}
fn media_type(content_type: &str) -> String {
content_type
.split(';')
.next()
.unwrap_or_default()
.trim()
.to_ascii_lowercase()
}
fn looks_like_graphql(
path: &str,
content_type: &str,
json: Option<&Value>,
body: Option<&[u8]>,
) -> bool {
if content_type == "application/graphql" || content_type.starts_with("application/graphql+") {
return true;
}
let path = path.to_ascii_lowercase();
let path_looks_graphql = path.contains("/graphql") || path.ends_with("graphql");
json.is_some_and(|value| value.get("query").and_then(Value::as_str).is_some())
|| (path_looks_graphql && body.is_some_and(|bytes| !bytes.is_empty()))
}
fn graphql_identity(
content_type: &str,
body: Option<&[u8]>,
json: Option<&Value>,
) -> Option<GraphqlIdentity> {
let (query, operation_name, variables) = if content_type == "application/graphql"
|| content_type.starts_with("application/graphql+")
{
let query = std::str::from_utf8(body?).ok()?.trim();
if query.is_empty() {
return None;
}
(query.to_owned(), None, Value::Object(Map::new()))
} else {
let json = json?;
let query = json.get("query").and_then(Value::as_str)?.to_owned();
if query.trim().is_empty() {
return None;
}
let operation_name = json
.get("operationName")
.and_then(Value::as_str)
.map(str::trim)
.filter(|name| !name.is_empty())
.map(ToOwned::to_owned);
let variables = json
.get("variables")
.cloned()
.unwrap_or_else(|| Value::Object(Map::new()));
(query, operation_name, variables)
};
let operation_name = operation_name.or_else(|| named_graphql_operation(&query));
Some(GraphqlIdentity {
operation_name,
query_digest: sha256_hex(normalise_graphql_query(&query).as_bytes()),
variables_digest: canonical_json_digest(&variables),
})
}
fn named_graphql_operation(query: &str) -> Option<String> {
let tokens = query.split_whitespace().collect::<Vec<_>>();
tokens.windows(2).find_map(|pair| {
matches!(pair[0], "query" | "mutation" | "subscription")
.then(|| pair[1])
.filter(|name| {
name.starts_with(|ch: char| ch.is_ascii_alphabetic() || ch == '_')
&& name
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
})
.map(ToOwned::to_owned)
})
}
fn normalise_graphql_query(query: &str) -> String {
query.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn canonical_json(value: &Value) -> Value {
match value {
Value::Object(map) => {
let sorted = map
.iter()
.map(|(key, child)| (key.clone(), canonical_json(child)))
.collect::<std::collections::BTreeMap<_, _>>();
Value::Object(sorted.into_iter().collect())
}
Value::Array(items) => Value::Array(items.iter().map(canonical_json).collect()),
other => other.clone(),
}
}
#[must_use]
pub fn bytes_digest(bytes: &[u8]) -> String {
sha256_hex(bytes)
}
fn sha256_hex(bytes: &[u8]) -> String {
Sha256::digest(bytes)
.iter()
.fold(String::with_capacity(64), |mut out, byte| {
let _ = write!(out, "{byte:02x}");
out
})
}