use std::collections::BTreeMap;
use std::sync::atomic::{AtomicBool, Ordering};
use fiftyone_json_builder::JSON_BUILDER_DATA_KEY;
use fiftyone_pipeline_core::constants::{
EVIDENCE_PROTOCOL_KEY, EVIDENCE_QUERY_PREFIX, EVIDENCE_SEPARATOR,
};
use fiftyone_pipeline_core::{
EvidenceKeyFilter, EvidenceKeyFilterWhitelist, FlowData, FlowElement, PropertyMetaData,
PropertyValue, PropertyValueType, Result,
};
use fiftyone_pipeline_engines_fiftyone::constants::{EVIDENCE_SEQUENCE, EVIDENCE_SESSIONID};
use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
use crate::constants::{
DELAY_EXECUTION_MARKER, EVIDENCE_ENABLE_COOKIES, EVIDENCE_HOST_KEY, EVIDENCE_OBJECT_NAME,
FALLBACK_PROTOCOL, FETCH_PROPERTY, JAVASCRIPT_BUILDER_ELEMENT_DATA_KEY,
JAVASCRIPT_PROPERTY_KEY, PROMISE_FULL_VALUE, PROMISE_PROPERTY,
};
use crate::data::{JavaScriptBuilderElementData, JAVASCRIPT_BUILDER_DATA_KEY};
use crate::minify::minify;
use crate::mustache::Template;
use crate::template_data::JavaScriptResource;
use crate::JavaScriptBuilderElementBuilder;
const TEMPLATE_SOURCE: &str = include_str!("../assets/JavaScriptResource.mustache");
const URL_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC
.remove(b'-')
.remove(b'_')
.remove(b'.')
.remove(b'*');
pub struct JavaScriptBuilderElement {
evidence_key_filter: EvidenceKeyFilterWhitelist,
properties: Vec<PropertyMetaData>,
template: Template,
host: String,
endpoint: String,
protocol: String,
object_name: String,
enable_cookies: bool,
minify: bool,
promise_property_available: AtomicBool,
fetch_property_available: AtomicBool,
}
impl JavaScriptBuilderElement {
pub fn new() -> Self {
JavaScriptBuilderElementBuilder::new().build()
}
pub fn builder() -> JavaScriptBuilderElementBuilder {
JavaScriptBuilderElementBuilder::new()
}
pub(crate) fn from_parts(
host: String,
endpoint: String,
protocol: String,
object_name: String,
enable_cookies: bool,
minify: bool,
) -> Self {
let template = Template::parse(TEMPLATE_SOURCE)
.expect("the embedded JavaScriptResource template is valid Mustache");
JavaScriptBuilderElement {
evidence_key_filter: EvidenceKeyFilterWhitelist::new([
EVIDENCE_HOST_KEY,
EVIDENCE_PROTOCOL_KEY,
EVIDENCE_OBJECT_NAME,
EVIDENCE_ENABLE_COOKIES,
]),
properties: vec![PropertyMetaData::new(
JAVASCRIPT_PROPERTY_KEY,
JAVASCRIPT_BUILDER_ELEMENT_DATA_KEY,
PropertyValueType::String,
)],
template,
host,
endpoint,
protocol,
object_name,
enable_cookies,
minify,
promise_property_available: AtomicBool::new(true),
fetch_property_available: AtomicBool::new(true),
}
}
fn resolve_protocol(&self, data: &FlowData) -> String {
if !self.protocol.is_empty() {
return self.protocol.clone();
}
if let Some(protocol) = data.evidence().get(EVIDENCE_PROTOCOL_KEY) {
if !protocol.is_empty() {
return protocol.to_owned();
}
}
FALLBACK_PROTOCOL.to_owned()
}
fn resolve_host(&self, data: &FlowData) -> String {
if !self.host.is_empty() {
return self.host.clone();
}
data.evidence()
.get(EVIDENCE_HOST_KEY)
.unwrap_or("")
.to_owned()
}
fn resolve_object_name(&self, data: &FlowData) -> String {
match data.evidence().get(EVIDENCE_OBJECT_NAME) {
Some(name) => name.to_owned(),
None => self.object_name.clone(),
}
}
fn resolve_enable_cookies(&self, data: &FlowData) -> bool {
match data.evidence().get(EVIDENCE_ENABLE_COOKIES) {
Some(value) => value.trim().parse::<bool>().unwrap_or(self.enable_cookies),
None => self.enable_cookies,
}
}
fn build_url(protocol: &str, host: &str, endpoint: &str) -> Option<String> {
if protocol.trim().is_empty() || host.trim().is_empty() || endpoint.trim().is_empty() {
return None;
}
let endpoint_has_slash = endpoint.starts_with('/');
let host_has_slash = host.ends_with('/');
let normalised_endpoint = if !endpoint_has_slash && !host_has_slash {
format!("/{endpoint}")
} else if endpoint_has_slash && host_has_slash {
endpoint[1..].to_owned()
} else {
endpoint.to_owned()
};
Some(format!("{protocol}://{host}{normalised_endpoint}"))
}
fn session_id(data: &FlowData) -> String {
data.evidence()
.get(EVIDENCE_SESSIONID)
.unwrap_or("")
.to_owned()
}
fn sequence(data: &FlowData) -> i32 {
data.evidence()
.get(EVIDENCE_SEQUENCE)
.and_then(|s| s.trim().parse::<i32>().ok())
.unwrap_or(1)
}
fn build_parameters(data: &FlowData) -> String {
let query_prefix = format!("{EVIDENCE_QUERY_PREFIX}{EVIDENCE_SEPARATOR}");
let mut parameters: BTreeMap<String, String> = BTreeMap::new();
for (key, value) in data.evidence().iter() {
if !key.starts_with(&query_prefix) {
continue;
}
if key.eq_ignore_ascii_case(EVIDENCE_SESSIONID)
|| key.eq_ignore_ascii_case(EVIDENCE_SEQUENCE)
{
continue;
}
let field = &key[query_prefix.len()..];
let encoded_key = utf8_percent_encode(field, URL_ENCODE_SET).to_string();
let encoded_value = utf8_percent_encode(value, URL_ENCODE_SET).to_string();
parameters.insert(encoded_key, encoded_value);
}
serde_json::to_string(¶meters).unwrap_or_else(|_| "{}".to_owned())
}
fn supports_property(
&self,
latch: &AtomicBool,
data: &FlowData,
property: &str,
predicate: impl Fn(&PropertyValue) -> bool,
) -> bool {
if !latch.load(Ordering::Relaxed) {
return false;
}
match data.get_evidence_or_property(property) {
Ok(value) => predicate(&value),
Err(_) => {
latch.store(false, Ordering::Relaxed);
false
}
}
}
fn supports_promises(&self, data: &FlowData) -> bool {
self.supports_property(
&self.promise_property_available,
data,
PROMISE_PROPERTY,
|value| value.as_str() == Some(PROMISE_FULL_VALUE),
)
}
fn supports_fetch(&self, data: &FlowData) -> bool {
self.supports_property(
&self.fetch_property_available,
data,
FETCH_PROPERTY,
|value| value.as_bool() == Some(true),
)
}
fn json_object(data: &FlowData) -> String {
data.get(JSON_BUILDER_DATA_KEY)
.map(|json| json.json().to_owned())
.unwrap_or_default()
}
fn build_javascript(&self, data: &FlowData) -> (String, bool) {
let protocol = self.resolve_protocol(data);
let host = self.resolve_host(data);
let object_name = self.resolve_object_name(data);
let enable_cookies = self.resolve_enable_cookies(data);
let supports_promises = self.supports_promises(data);
let supports_fetch = self.supports_fetch(data);
let json_object = Self::json_object(data);
let parameters = Self::build_parameters(data);
let session_id = Self::session_id(data);
let sequence = Self::sequence(data);
let url = Self::build_url(&protocol, &host, &self.endpoint);
let update_enabled = url.as_ref().is_some_and(|u| !u.is_empty());
let has_delayed_properties = json_object.contains(DELAY_EXECUTION_MARKER);
let resource = JavaScriptResource::new(
object_name,
json_object,
session_id,
sequence,
supports_promises,
supports_fetch,
url.unwrap_or_default(),
parameters,
enable_cookies,
update_enabled,
has_delayed_properties,
);
let content = resource.render(&self.template);
let outcome = if self.minify {
minify(content)
} else {
crate::minify::MinifyOutcome {
content,
had_error: false,
}
};
(outcome.content, outcome.had_error)
}
}
impl Default for JavaScriptBuilderElement {
fn default() -> Self {
JavaScriptBuilderElement::new()
}
}
impl FlowElement for JavaScriptBuilderElement {
fn process(&self, data: &mut FlowData) -> Result<()> {
let (content, _had_error) = self.build_javascript(data);
let result = data.get_or_add(
JAVASCRIPT_BUILDER_DATA_KEY,
JavaScriptBuilderElementData::new,
);
match result {
Ok(element_data) => {
element_data.set_javascript(content);
Ok(())
}
Err(error) => Err(error),
}
}
fn data_key(&self) -> &str {
JAVASCRIPT_BUILDER_ELEMENT_DATA_KEY
}
fn evidence_key_filter(&self) -> &dyn EvidenceKeyFilter {
&self.evidence_key_filter
}
fn properties(&self) -> &[PropertyMetaData] {
&self.properties
}
}
#[cfg(test)]
mod tests {
use super::*;
use fiftyone_pipeline_core::{Evidence, Pipeline};
use std::sync::Arc;
fn flow_data_with(pairs: &[(&str, &str)]) -> FlowData {
let mut builder = Evidence::builder();
for (key, value) in pairs {
builder = builder.add(*key, *value);
}
let pipeline = Pipeline::builder()
.add_element(Arc::new(JavaScriptBuilderElement::new()))
.build()
.expect("pipeline builds");
pipeline.create_flow_data_with(builder.build())
}
#[test]
fn build_url_adds_single_slash() {
let url = JavaScriptBuilderElement::build_url("https", "example.com", "51dpipeline/json");
assert_eq!(url.as_deref(), Some("https://example.com/51dpipeline/json"));
}
#[test]
fn build_url_keeps_single_slash_on_endpoint() {
let url = JavaScriptBuilderElement::build_url("https", "example.com", "/51dpipeline/json");
assert_eq!(url.as_deref(), Some("https://example.com/51dpipeline/json"));
}
#[test]
fn build_url_collapses_double_slash() {
let url = JavaScriptBuilderElement::build_url("http", "example.com/", "/json");
assert_eq!(url.as_deref(), Some("http://example.com/json"));
}
#[test]
fn build_url_none_when_host_missing() {
assert!(JavaScriptBuilderElement::build_url("https", "", "/json").is_none());
assert!(JavaScriptBuilderElement::build_url("", "example.com", "/json").is_none());
assert!(JavaScriptBuilderElement::build_url("https", "example.com", "").is_none());
}
#[test]
fn parameters_exclude_session_and_sequence() {
let data = flow_data_with(&[
("query.session-id", "abc"),
("query.sequence", "3"),
("query.user-agent", "test agent"),
("query.fod-js-object-name", "myObj"),
("header.host", "ignored"),
]);
let json = JavaScriptBuilderElement::build_parameters(&data);
let value: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
let object = value.as_object().expect("an object");
assert!(!object.contains_key("session-id"));
assert!(!object.contains_key("sequence"));
assert!(!object.values().any(|v| v == "ignored"));
assert_eq!(
object.get("user-agent").and_then(|v| v.as_str()),
Some("test%20agent")
);
assert_eq!(
object.get("fod-js-object-name").and_then(|v| v.as_str()),
Some("myObj")
);
}
#[test]
fn parameters_are_deterministic() {
let data = flow_data_with(&[("query.b", "2"), ("query.a", "1"), ("query.c", "3")]);
let first = JavaScriptBuilderElement::build_parameters(&data);
let second = JavaScriptBuilderElement::build_parameters(&data);
assert_eq!(first, second);
let a = first.find("\"a\"").unwrap();
let b = first.find("\"b\"").unwrap();
let c = first.find("\"c\"").unwrap();
assert!(a < b && b < c);
}
#[test]
fn protocol_falls_back_to_https() {
let element = JavaScriptBuilderElement::new();
let data = flow_data_with(&[("header.host", "example.com")]);
assert_eq!(element.resolve_protocol(&data), "https");
}
#[test]
fn protocol_taken_from_evidence_when_not_configured() {
let element = JavaScriptBuilderElement::new();
let data = flow_data_with(&[("header.protocol", "http")]);
assert_eq!(element.resolve_protocol(&data), "http");
}
#[test]
fn object_name_overridden_by_evidence() {
let element = JavaScriptBuilderElement::new();
let data = flow_data_with(&[("query.fod-js-object-name", "custom")]);
assert_eq!(element.resolve_object_name(&data), "custom");
let data = flow_data_with(&[]);
assert_eq!(element.resolve_object_name(&data), "fod");
}
#[test]
fn enable_cookies_overridden_by_evidence() {
let element = JavaScriptBuilderElement::new();
let data = flow_data_with(&[("query.fod-js-enable-cookies", "false")]);
assert!(!element.resolve_enable_cookies(&data));
let data = flow_data_with(&[]);
assert!(element.resolve_enable_cookies(&data));
}
#[test]
fn promise_and_fetch_latch_off_when_unavailable() {
let element = JavaScriptBuilderElement::new();
let data = flow_data_with(&[]);
assert!(!element.supports_promises(&data));
assert!(!element.supports_fetch(&data));
assert!(!element.promise_property_available.load(Ordering::Relaxed));
assert!(!element.fetch_property_available.load(Ordering::Relaxed));
assert!(!element.supports_promises(&data));
assert!(!element.supports_fetch(&data));
}
}