use schemars::JsonSchema;
use serde::{de::Error as _, Deserialize, Serialize};
use std::collections::HashSet;
use std::time::Duration;
use crate::config::primitives::file_path::FilePath;
use crate::config::primitives::retry_policy::RetryPolicyConfig;
use crate::config::primitives::single_or_multiple::SingleOrMultiple;
use crate::config::primitives::toggle::ToggleWith;
use crate::config::primitives::value_or_expression::ValueOrExpression;
#[derive(Debug, Serialize, JsonSchema, Clone, Default)]
pub struct PersistedDocumentsConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub require_id: ValueOrExpression<bool>,
#[serde(default)]
pub log_missing_id: bool,
#[serde(default)]
pub storage: Option<PersistedDocumentsStorageConfig>,
#[serde(default)]
pub selectors: Option<Vec<PersistedDocumentExtractorConfig>>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawPersistedDocumentsConfig {
#[serde(default)]
enabled: bool,
#[serde(default)]
require_id: ValueOrExpression<bool>,
#[serde(default)]
log_missing_id: bool,
#[serde(default)]
storage: Option<PersistedDocumentsStorageConfig>,
#[serde(default)]
selectors: Option<Vec<PersistedDocumentExtractorConfig>>,
}
impl<'de> Deserialize<'de> for PersistedDocumentsConfig {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = RawPersistedDocumentsConfig::deserialize(deserializer)?;
if raw.enabled && matches!(raw.selectors.as_ref(), Some(selectors) if selectors.is_empty())
{
return Err(D::Error::custom(
"persisted_documents.selectors must not be an explicit empty list when persisted_documents.enabled=true",
));
}
if raw.enabled && raw.storage.is_none() {
return Err(D::Error::custom(
"persisted_documents.storage is required when persisted_documents.enabled=true",
));
}
if let Some(selectors) = raw.selectors.as_ref() {
let mut seen = HashSet::new();
for selector in selectors {
if !seen.insert(selector.clone()) {
return Err(D::Error::custom(format!(
"persisted_documents.selectors contains a duplicate entry: {selector:?}"
)));
}
}
}
Ok(Self {
enabled: raw.enabled,
require_id: raw.require_id,
log_missing_id: raw.log_missing_id,
storage: raw.storage,
selectors: raw.selectors,
})
}
}
#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)]
#[serde(deny_unknown_fields, tag = "type", rename_all = "snake_case")]
pub enum PersistedDocumentsStorageConfig {
File {
#[serde(flatten)]
config: PersistedDocumentsFileStorageConfig,
},
Hive {
#[serde(flatten)]
config: PersistedDocumentsHiveStorageConfig,
},
Storage {
#[serde(flatten)]
config: PersistedDocumentsStorageRefConfig,
},
}
#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)]
#[serde(deny_unknown_fields)]
pub struct PersistedDocumentsFileStorageConfig {
pub path: FilePath,
#[serde(default = "default_watch")]
pub watch: bool,
}
#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)]
#[serde(deny_unknown_fields)]
pub struct PersistedDocumentsStorageRefConfig {
pub storage_id: String,
pub location: String,
#[serde(
default = "default_storage_poll_interval",
deserialize_with = "humantime_serde::deserialize",
serialize_with = "humantime_serde::serialize"
)]
#[schemars(with = "String")]
pub poll_interval: Option<Duration>,
}
fn default_storage_poll_interval() -> Option<Duration> {
None
}
#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)]
#[serde(deny_unknown_fields)]
pub struct PersistedDocumentsHiveStorageConfig {
pub endpoint: Option<SingleOrMultiple<String>>,
pub key: Option<String>,
#[serde(default = "default_hive_accept_invalid_certs")]
pub accept_invalid_certs: bool,
#[serde(
default = "default_hive_connect_timeout",
deserialize_with = "humantime_serde::deserialize",
serialize_with = "humantime_serde::serialize"
)]
#[schemars(with = "String")]
pub connect_timeout: Duration,
#[serde(
default = "default_hive_request_timeout",
deserialize_with = "humantime_serde::deserialize",
serialize_with = "humantime_serde::serialize"
)]
#[schemars(with = "String")]
pub request_timeout: Duration,
#[serde(default = "default_hive_retry_policy")]
pub retry_policy: RetryPolicyConfig,
#[serde(default = "default_hive_cache_size")]
pub cache_size: u64,
#[serde(default)]
pub circuit_breaker: PersistedDocumentsHiveCircuitBreakerConfig,
#[serde(default = "default_hive_negative_cache")]
pub negative_cache: ToggleWith<PersistedDocumentsHiveNegativeCacheConfig>,
}
#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct PersistedDocumentsHiveNegativeCacheConfig {
#[serde(
deserialize_with = "humantime_serde::deserialize",
serialize_with = "humantime_serde::serialize"
)]
#[schemars(with = "String")]
pub ttl: Duration,
}
impl Default for PersistedDocumentsHiveNegativeCacheConfig {
fn default() -> Self {
Self {
ttl: Duration::from_secs(5),
}
}
}
#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)]
#[serde(deny_unknown_fields)]
pub struct PersistedDocumentsHiveCircuitBreakerConfig {
#[serde(default = "default_circuit_breaker_error_threshold")]
pub error_threshold: f32,
#[serde(default = "default_circuit_breaker_volume_threshold")]
pub volume_threshold: usize,
#[serde(
default = "default_circuit_breaker_reset_timeout",
deserialize_with = "humantime_serde::deserialize",
serialize_with = "humantime_serde::serialize"
)]
#[schemars(with = "String")]
pub reset_timeout: Duration,
}
impl Default for PersistedDocumentsHiveCircuitBreakerConfig {
fn default() -> Self {
Self {
error_threshold: default_circuit_breaker_error_threshold(),
volume_threshold: default_circuit_breaker_volume_threshold(),
reset_timeout: default_circuit_breaker_reset_timeout(),
}
}
}
fn default_hive_accept_invalid_certs() -> bool {
false
}
fn default_hive_connect_timeout() -> Duration {
Duration::from_secs(5)
}
fn default_hive_request_timeout() -> Duration {
Duration::from_secs(15)
}
fn default_hive_retry_policy() -> RetryPolicyConfig {
RetryPolicyConfig { max_retries: 3 }
}
fn default_hive_cache_size() -> u64 {
10_000
}
fn default_hive_negative_cache() -> ToggleWith<PersistedDocumentsHiveNegativeCacheConfig> {
ToggleWith::Enabled(PersistedDocumentsHiveNegativeCacheConfig::default())
}
fn default_circuit_breaker_error_threshold() -> f32 {
0.5
}
fn default_circuit_breaker_volume_threshold() -> usize {
5
}
fn default_circuit_breaker_reset_timeout() -> Duration {
Duration::from_secs(10)
}
const fn default_watch() -> bool {
true
}
#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, PartialEq, Eq, Hash)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PersistedDocumentExtractorConfig {
JsonPath {
path: PersistedDocumentJsonPath,
},
UrlPathParam {
template: PersistedDocumentUrlTemplate,
},
UrlQueryParam {
name: PersistedDocumentQueryParamName,
},
}
#[derive(Debug, Serialize, JsonSchema, Clone, PartialEq, Eq, Hash)]
#[serde(transparent)]
pub struct PersistedDocumentJsonPath(String);
impl PersistedDocumentJsonPath {
pub fn as_str(&self) -> &str {
&self.0
}
}
impl<'de> Deserialize<'de> for PersistedDocumentJsonPath {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let path = String::deserialize(deserializer)?;
if path.is_empty() {
return Err(D::Error::custom("json_path cannot be empty"));
}
if path.chars().any(char::is_whitespace) {
return Err(D::Error::custom("json_path cannot include whitespace"));
}
if path.contains('[') || path.contains(']') {
return Err(D::Error::custom("json_path cannot include array syntax"));
}
if path.contains('*') {
return Err(D::Error::custom("json_path cannot include wildcard syntax"));
}
if path.split('.').any(str::is_empty) {
return Err(D::Error::custom(
"json_path cannot include empty segments (e.g. '..')",
));
}
if matches!(
path.split('.').next(),
Some("query" | "operationName" | "variables")
) {
return Err(D::Error::custom(
"json_path cannot access root GraphQL fields: query, operationName, variables",
));
}
Ok(Self(path))
}
}
#[derive(Debug, Serialize, JsonSchema, Clone, PartialEq, Eq, Hash)]
#[serde(transparent)]
pub struct PersistedDocumentUrlTemplate(String);
impl PersistedDocumentUrlTemplate {
pub fn as_str(&self) -> &str {
&self.0
}
}
impl<'de> Deserialize<'de> for PersistedDocumentUrlTemplate {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let template = String::deserialize(deserializer)?;
validate_url_path_template(&template).map_err(D::Error::custom)?;
Ok(Self(template))
}
}
fn validate_url_path_template(template: &str) -> Result<(), String> {
if template.is_empty() {
return Err("url_path_param.template cannot be empty".to_string());
}
if !template.starts_with('/') {
return Err("url_path_param.template must start with '/'".to_string());
}
if template.contains('?') || template.contains('#') {
return Err("url_path_param.template cannot include query string or fragment".to_string());
}
let raw_segments: Vec<&str> = template.split('/').skip(1).collect();
if raw_segments.iter().any(|segment| segment.is_empty()) {
return Err("url_path_param.template cannot include empty segments".to_string());
}
let mut id_count = 0;
for (index, segment) in raw_segments.iter().enumerate() {
match *segment {
":id" => id_count += 1,
"*" => {}
"**" => {
return Err("url_path_param.template does not support '**' segments".to_string());
}
literal if literal.starts_with(':') => {
return Err(format!(
"url_path_param.template has unsupported parameter segment '{literal}' at index {index}; only ':id' is allowed"
));
}
_ => {}
}
}
if id_count != 1 {
return Err("url_path_param.template must include exactly one ':id' segment".to_string());
}
Ok(())
}
#[derive(Debug, Serialize, JsonSchema, Clone, PartialEq, Eq, Hash)]
#[serde(transparent)]
pub struct PersistedDocumentQueryParamName(String);
impl PersistedDocumentQueryParamName {
pub fn as_str(&self) -> &str {
&self.0
}
}
impl<'de> Deserialize<'de> for PersistedDocumentQueryParamName {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let name = String::deserialize(deserializer)?;
if name.trim().is_empty() {
return Err(D::Error::custom("url_query_param.name cannot be empty"));
}
Ok(Self(name))
}
}
impl PersistedDocumentsConfig {
pub fn default_selectors() -> Vec<PersistedDocumentExtractorConfig> {
vec![
PersistedDocumentExtractorConfig::JsonPath {
path: PersistedDocumentJsonPath("documentId".to_string()),
},
PersistedDocumentExtractorConfig::JsonPath {
path: PersistedDocumentJsonPath("extensions.persistedQuery.sha256Hash".to_string()),
},
]
}
}
#[cfg(test)]
mod tests {
use super::{
PersistedDocumentJsonPath, PersistedDocumentUrlTemplate, PersistedDocumentsConfig,
};
#[test]
fn rejects_root_graphql_fields_for_json_path() {
for path in ["query", "operationName", "variables", "query.foo"] {
let raw = format!("\"{path}\"");
let parsed = serde_json::from_str::<PersistedDocumentJsonPath>(&raw);
assert!(parsed.is_err(), "expected path '{path}' to be rejected");
}
}
#[test]
fn allows_non_root_graphql_fields_for_json_path() {
for path in [
"documentId",
"extensions.persistedQuery.sha256Hash",
"foo.query",
] {
let raw = format!("\"{path}\"");
let parsed = serde_json::from_str::<PersistedDocumentJsonPath>(&raw);
assert!(parsed.is_ok(), "expected path '{path}' to be allowed");
}
}
#[test]
fn enabled_persisted_documents_require_storage() {
let parsed = serde_json::from_str::<PersistedDocumentsConfig>(
r#"{
"enabled": true
}"#,
);
assert!(
parsed.is_err(),
"expected storage to be required when enabled"
);
}
#[test]
fn url_template_rejects_unknown_parameter_segment() {
let parsed = serde_json::from_str::<PersistedDocumentUrlTemplate>(r#""/p/:docId""#);
assert!(parsed.is_err(), "expected unknown parameter to be rejected");
}
#[test]
fn url_template_accepts_supported_segment_types() {
for template in ["/v1/p/:id", "/v1/*/:id", "/v1/*/:id/details"] {
let raw = format!("\"{template}\"");
let parsed = serde_json::from_str::<PersistedDocumentUrlTemplate>(&raw);
assert!(parsed.is_ok(), "expected template '{template}' to be valid");
}
}
#[test]
fn url_template_rejects_globstar_segment() {
for template in ["/v1/**/:id", "/:id/**/v2"] {
let raw = format!("\"{template}\"");
let parsed = serde_json::from_str::<PersistedDocumentUrlTemplate>(&raw);
assert!(
parsed.is_err(),
"expected template '{template}' to be rejected"
);
}
}
}