use crate::config::advanced::HttpClient;
use crate::error::Error::ParseError;
use crate::error::{Error, Result};
use crate::http::client::HttpClientConfig;
use heck::ToTrainCase;
use http::{HeaderMap, Uri};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::time::Duration;
use wildmatch::WildMatch;
const DEFAULT_TTL_CEILING_SECS: u64 = 3600;
const DEFAULT_CACHE_CAPACITY: u64 = 10000;
#[derive(Serialize, Deserialize, JsonSchema, Debug, Clone, PartialEq, Eq)]
#[serde(deny_unknown_fields, default)]
pub struct CachePolicy {
#[serde(default = "default_ttl_ceiling_secs")]
ttl_ceiling_secs: u64,
#[serde(default)]
store: CacheStore,
}
fn default_ttl_ceiling_secs() -> u64 {
DEFAULT_TTL_CEILING_SECS
}
impl Default for CachePolicy {
fn default() -> Self {
Self {
ttl_ceiling_secs: DEFAULT_TTL_CEILING_SECS,
store: CacheStore::default(),
}
}
}
impl CachePolicy {
pub fn new(ttl_ceiling_secs: u64, store: CacheStore) -> Self {
Self {
ttl_ceiling_secs,
store,
}
}
pub fn ttl_ceiling(&self) -> Duration {
Duration::from_secs(self.ttl_ceiling_secs)
}
pub fn ttl_ceiling_secs(&self) -> u64 {
self.ttl_ceiling_secs
}
pub fn store(&self) -> &CacheStore {
&self.store
}
}
#[derive(Serialize, Deserialize, JsonSchema, Debug, Clone, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum CacheStore {
InMemory {
#[serde(default = "default_cache_capacity")]
capacity: u64,
},
Disk,
}
fn default_cache_capacity() -> u64 {
DEFAULT_CACHE_CAPACITY
}
impl Default for CacheStore {
fn default() -> Self {
CacheStore::InMemory {
capacity: DEFAULT_CACHE_CAPACITY,
}
}
}
#[derive(Deserialize, Debug, Clone)]
#[serde(deny_unknown_fields)]
pub struct Callout {
#[serde(with = "http_serde::uri")]
url: Uri,
#[serde(default = "default_http_client", alias = "tls")]
http: HttpClient,
#[serde(default)]
forward: Forward,
}
fn default_http_client() -> HttpClient {
HttpClient::from(HttpClientConfig::default())
}
impl Callout {
pub fn new(url: Uri, http: HttpClient, forward: Forward) -> Self {
Self { url, http, forward }
}
pub fn url(&self) -> &Uri {
&self.url
}
pub fn http(&self) -> &HttpClient {
&self.http
}
pub fn http_mut(&mut self) -> &mut HttpClient {
&mut self.http
}
pub fn forward(&self) -> &Forward {
&self.forward
}
}
#[derive(Serialize, Deserialize, JsonSchema, Debug, Clone, Default, PartialEq, Eq)]
#[serde(deny_unknown_fields, default)]
pub struct Forward {
headers: HeaderRules,
context: ContextRules,
}
impl Forward {
pub fn new(headers: HeaderRules, context: ContextRules) -> Self {
Self { headers, context }
}
pub fn headers(&self) -> &HeaderRules {
&self.headers
}
pub fn context(&self) -> &ContextRules {
&self.context
}
}
#[derive(Serialize, Deserialize, JsonSchema, Debug, Clone, Default, PartialEq, Eq)]
#[serde(deny_unknown_fields, default)]
pub struct HeaderRules {
allow: Vec<String>,
deny: Vec<String>,
}
impl HeaderRules {
pub fn new(allow: Vec<String>, deny: Vec<String>) -> Self {
Self { allow, deny }
}
pub fn allow(&self) -> &[String] {
&self.allow
}
pub fn deny(&self) -> &[String] {
&self.deny
}
pub fn filter(&self, headers: &HeaderMap) -> HeaderMap {
let allow: Vec<_> = self
.allow
.iter()
.map(|p| WildMatch::new(&p.to_lowercase()))
.collect();
let deny: Vec<_> = self
.deny
.iter()
.map(|p| WildMatch::new(&p.to_lowercase()))
.collect();
if allow.is_empty() {
return HeaderMap::new();
}
let mut result = HeaderMap::new();
for (name, value) in headers {
let lowered = name.as_str().to_lowercase();
if allow.iter().any(|p| p.matches(&lowered)) && !deny.iter().any(|p| p.matches(&lowered)) {
result.insert(name, value.clone());
}
}
result
}
}
#[derive(Serialize, Deserialize, JsonSchema, Debug, Clone, Default, PartialEq, Eq)]
#[serde(deny_unknown_fields, default)]
pub struct ContextRules {
endpoint_type: bool,
id: bool,
extensions: Vec<ContextExtension>,
}
impl ContextRules {
pub fn new(endpoint_type: bool, id: bool, extensions: Vec<ContextExtension>) -> Self {
Self {
endpoint_type,
id,
extensions,
}
}
pub fn endpoint_type(&self) -> bool {
self.endpoint_type
}
pub fn id(&self) -> bool {
self.id
}
pub fn extensions(&self) -> &[ContextExtension] {
&self.extensions
}
}
#[derive(Serialize, Deserialize, JsonSchema, Debug, Clone, PartialEq, Eq)]
#[serde(deny_unknown_fields, try_from = "ContextExtensionRaw")]
pub struct ContextExtension {
json_path: String,
name: String,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct ContextExtensionRaw {
json_path: String,
#[serde(default)]
name: Option<String>,
}
impl ContextExtensionRaw {
fn derive_name(json_path: &str) -> Result<String> {
let derived = json_path.to_train_case();
if derived.is_empty() {
return Err(ParseError(format!(
"cannot derive header name from JSONPath `{json_path}`, specify `name` explicitly"
)));
}
Ok(derived)
}
}
impl TryFrom<ContextExtensionRaw> for ContextExtension {
type Error = Error;
fn try_from(raw: ContextExtensionRaw) -> Result<Self> {
let name = match raw.name {
Some(name) => name,
None => ContextExtensionRaw::derive_name(&raw.json_path)?,
};
Ok(Self {
json_path: raw.json_path,
name,
})
}
}
impl ContextExtension {
pub fn new(json_path: String, name: String) -> Self {
Self { json_path, name }
}
pub fn json_path(&self) -> &str {
&self.json_path
}
pub fn name(&self) -> &str {
&self.name
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(try_from = "ParseRaw", into = "ParseRaw")]
pub enum Parse {
Bytes { ticket_url: Option<Uri> },
JsonPath {
content_path: String,
size_path: Option<String>,
ticket: Option<TicketSource>,
},
}
impl JsonSchema for Parse {
fn schema_name() -> std::borrow::Cow<'static, str> {
ParseRaw::schema_name()
}
fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
ParseRaw::json_schema(generator)
}
}
impl Default for Parse {
fn default() -> Self {
Parse::Bytes { ticket_url: None }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TicketSource {
JsonPath { path: String },
Url { url: Uri },
}
#[derive(Serialize, Deserialize, JsonSchema, Debug, Clone)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub(crate) enum ParseRaw {
Bytes {
#[serde(default, with = "http_serde::option::uri")]
#[schemars(with = "Option<String>")]
ticket_url: Option<Uri>,
},
JsonPath {
content_path: String,
#[serde(default)]
size_path: Option<String>,
#[serde(default)]
ticket_path: Option<String>,
#[serde(default, with = "http_serde::option::uri")]
#[schemars(with = "Option<String>")]
ticket_url: Option<Uri>,
},
}
impl From<Parse> for ParseRaw {
fn from(parse: Parse) -> Self {
match parse {
Parse::Bytes { ticket_url } => ParseRaw::Bytes { ticket_url },
Parse::JsonPath {
content_path,
size_path,
ticket,
} => {
let (ticket_path, ticket_url) = match ticket {
None => (None, None),
Some(TicketSource::JsonPath { path }) => (Some(path), None),
Some(TicketSource::Url { url }) => (None, Some(url)),
};
ParseRaw::JsonPath {
content_path,
size_path,
ticket_path,
ticket_url,
}
}
}
}
}
impl TryFrom<ParseRaw> for Parse {
type Error = Error;
fn try_from(raw: ParseRaw) -> Result<Self> {
match raw {
ParseRaw::Bytes { ticket_url } => Ok(Parse::Bytes { ticket_url }),
ParseRaw::JsonPath {
content_path,
size_path,
ticket_path,
ticket_url,
} => {
let ticket = match (ticket_path, ticket_url) {
(None, None) => None,
(None, Some(url)) => Some(TicketSource::Url { url }),
(Some(path), None) => Some(TicketSource::JsonPath { path }),
(Some(_), Some(_)) => {
return Err(ParseError(
"cannot specify both `ticket_path` and `ticket_url`".to_string(),
));
}
};
Ok(Parse::JsonPath {
content_path,
size_path,
ticket,
})
}
}
}
}
#[derive(Serialize, Deserialize, JsonSchema, Debug, Clone, Default, PartialEq, Eq)]
#[serde(deny_unknown_fields, default)]
pub struct Reflect {
headers: HeaderRules,
}
impl Reflect {
pub fn new(headers: HeaderRules) -> Self {
Self { headers }
}
pub fn headers(&self) -> &HeaderRules {
&self.headers
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn callout_minimal() {
let toml = r#"url = "https://example.com""#;
let callout: Callout = toml::from_str(toml).unwrap();
assert_eq!(callout.url().to_string(), "https://example.com/");
assert!(callout.forward().headers().allow().is_empty());
assert!(callout.forward().headers().deny().is_empty());
assert!(!callout.forward().context().endpoint_type());
assert!(!callout.forward().context().id());
assert!(callout.forward().context().extensions().is_empty());
}
#[test]
fn callout_complex() {
let toml = r#"
url = "https://example.com"
[forward]
headers.allow = ["Authorization", "X-Custom-*"]
headers.deny = ["X-Internal-*"]
[forward.context]
endpoint_type = true
id = true
extensions = [{ json_path = "$.custom", name = "Custom-Name" }]
"#;
let callout: Callout = toml::from_str(toml).unwrap();
assert_eq!(
callout.forward().headers().allow(),
&["Authorization".to_string(), "X-Custom-*".to_string()]
);
assert_eq!(
callout.forward().headers().deny(),
&["X-Internal-*".to_string()]
);
assert!(callout.forward().context().endpoint_type());
assert!(callout.forward().context().id());
assert_eq!(
callout.forward().context().extensions(),
&[ContextExtension::new(
"$.custom".to_string(),
"Custom-Name".to_string()
)]
);
}
#[test]
fn context_extension_derives_names() {
let toml = r#"json_path = "$.custom_id""#;
let ext: ContextExtension = toml::from_str(toml).unwrap();
assert_eq!(ext.name(), "Custom-Id");
let toml = r#"json_path = "$.user.custom_id""#;
let ext: ContextExtension = toml::from_str(toml).unwrap();
assert_eq!(ext.name(), "User-Custom-Id");
let toml = r#"json_path = "$..custom""#;
let ext: ContextExtension = toml::from_str(toml).unwrap();
assert_eq!(ext.name(), "Custom");
let toml = r#"json_path = "$.user.custom_id[0]""#;
let ext: ContextExtension = toml::from_str(toml).unwrap();
assert_eq!(ext.name(), "User-Custom-Id-0");
let toml = r#"json_path = "$.""#;
assert!(toml::from_str::<ContextExtension>(toml).is_err());
}
#[test]
fn parse_bytes() {
let toml = r#"kind = "bytes""#;
let parse = toml::from_str(toml).unwrap();
assert!(matches!(parse, Parse::Bytes { ticket_url: None }));
let toml = r#"
kind = "bytes"
ticket_url = "https://example.com"
"#;
let parse = toml::from_str(toml).unwrap();
match parse {
Parse::Bytes { ticket_url } => {
assert_eq!(ticket_url.unwrap().to_string(), "https://example.com/");
}
_ => panic!(),
}
}
#[test]
fn parse_json_path() {
let toml = r#"
kind = "json_path"
content_path = "$.content"
size_path = "$.size"
ticket_path = "$.response"
"#;
let parse = toml::from_str(toml).unwrap();
match parse {
Parse::JsonPath {
content_path,
size_path,
ticket,
} => {
assert_eq!(content_path, "$.content");
assert_eq!(size_path.as_deref(), Some("$.size"));
assert_eq!(
ticket,
Some(TicketSource::JsonPath {
path: "$.response".to_string()
}),
);
}
_ => panic!(),
}
let toml = r#"
kind = "json_path"
content_path = "$.content"
ticket_url = "https://example.com"
"#;
let parse = toml::from_str(toml).unwrap();
match parse {
Parse::JsonPath { ticket, .. } => {
assert_eq!(
ticket,
Some(TicketSource::Url {
url: "https://example.com".parse().unwrap()
}),
);
}
_ => panic!(),
}
let toml = r#"
kind = "json_path"
content_path = "$.content"
"#;
let parse = toml::from_str(toml).unwrap();
match parse {
Parse::JsonPath { ticket, .. } => {
assert!(ticket.is_none(),);
}
_ => panic!(),
}
let toml = r#"
kind = "json_path"
content_path = "$.content"
ticket_path = "$.response"
ticket_url = "https://example.com"
"#;
let parse = toml::from_str::<Parse>(toml);
assert!(parse.is_err());
}
#[test]
fn reflect_default() {
let reflect: Reflect = toml::from_str("").unwrap();
assert!(reflect.headers().allow().is_empty());
assert!(reflect.headers().deny().is_empty());
let toml = r#"
headers.allow = ["Authorization"]
headers.deny = ["X-Custom-*"]
"#;
let reflect: Reflect = toml::from_str(toml).unwrap();
assert_eq!(reflect.headers().allow(), &["Authorization".to_string()]);
assert_eq!(reflect.headers().deny(), &["X-Custom-*".to_string()]);
}
#[test]
fn cache_policy_ttl() {
let toml = r#"ttl_ceiling_secs = 7200"#;
let policy: CachePolicy = toml::from_str(toml).unwrap();
assert_eq!(policy.ttl_ceiling_secs(), 7200);
}
#[test]
fn cache_policy_store() {
let toml = r#"
ttl_ceiling_secs = 1800
[store]
kind = "in_memory"
capacity = 200
"#;
let policy: CachePolicy = toml::from_str(toml).unwrap();
assert_eq!(policy.ttl_ceiling_secs(), 1800);
assert_eq!(policy.store(), &CacheStore::InMemory { capacity: 200 });
let toml = r#"
[store]
kind = "disk"
"#;
let policy: CachePolicy = toml::from_str(toml).unwrap();
assert_eq!(policy.store(), &CacheStore::Disk);
}
#[test]
fn cache_policy_http_config() {
let toml = r#"
url = "https://example.com"
[http]
use_cache = true
cache.ttl_ceiling_secs = 300
[http.cache.store]
kind = "in_memory"
capacity = 50
"#;
let callout: Callout = toml::from_str(toml).unwrap();
assert_eq!(callout.url().to_string(), "https://example.com/");
}
#[test]
fn callout_default_http_cache_policy() {
let toml = r#"url = "https://example.com""#;
let callout: Callout = toml::from_str(toml).unwrap();
assert_eq!(callout.url().to_string(), "https://example.com/");
}
}