fiftyone_javascript_builder/
element.rs1use std::collections::BTreeMap;
26use std::sync::atomic::{AtomicBool, Ordering};
27
28use fiftyone_json_builder::JSON_BUILDER_DATA_KEY;
29use fiftyone_pipeline_core::constants::{
30 EVIDENCE_PROTOCOL_KEY, EVIDENCE_QUERY_PREFIX, EVIDENCE_SEPARATOR,
31};
32use fiftyone_pipeline_core::{
33 EvidenceKeyFilter, EvidenceKeyFilterWhitelist, FlowData, FlowElement, PropertyMetaData,
34 PropertyValue, PropertyValueType, Result,
35};
36use fiftyone_pipeline_engines_fiftyone::constants::{EVIDENCE_SEQUENCE, EVIDENCE_SESSIONID};
37use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
38
39use crate::constants::{
40 DELAY_EXECUTION_MARKER, EVIDENCE_ENABLE_COOKIES, EVIDENCE_HOST_KEY, EVIDENCE_OBJECT_NAME,
41 FALLBACK_PROTOCOL, FETCH_PROPERTY, JAVASCRIPT_BUILDER_ELEMENT_DATA_KEY,
42 JAVASCRIPT_PROPERTY_KEY, PROMISE_FULL_VALUE, PROMISE_PROPERTY,
43};
44use crate::data::{JavaScriptBuilderElementData, JAVASCRIPT_BUILDER_DATA_KEY};
45use crate::minify::minify;
46use crate::mustache::Template;
47use crate::template_data::JavaScriptResource;
48use crate::JavaScriptBuilderElementBuilder;
49
50const TEMPLATE_SOURCE: &str = include_str!("../assets/JavaScriptResource.mustache");
52
53const URL_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC
62 .remove(b'-')
63 .remove(b'_')
64 .remove(b'.')
65 .remove(b'*');
66
67pub struct JavaScriptBuilderElement {
125 evidence_key_filter: EvidenceKeyFilterWhitelist,
126 properties: Vec<PropertyMetaData>,
127 template: Template,
128
129 host: String,
130 endpoint: String,
131 protocol: String,
132 object_name: String,
133 enable_cookies: bool,
134 minify: bool,
135
136 promise_property_available: AtomicBool,
141 fetch_property_available: AtomicBool,
142}
143
144impl JavaScriptBuilderElement {
145 pub fn new() -> Self {
147 JavaScriptBuilderElementBuilder::new().build()
148 }
149
150 pub fn builder() -> JavaScriptBuilderElementBuilder {
152 JavaScriptBuilderElementBuilder::new()
153 }
154
155 pub(crate) fn from_parts(
157 host: String,
158 endpoint: String,
159 protocol: String,
160 object_name: String,
161 enable_cookies: bool,
162 minify: bool,
163 ) -> Self {
164 let template = Template::parse(TEMPLATE_SOURCE)
168 .expect("the embedded JavaScriptResource template is valid Mustache");
169
170 JavaScriptBuilderElement {
171 evidence_key_filter: EvidenceKeyFilterWhitelist::new([
172 EVIDENCE_HOST_KEY,
173 EVIDENCE_PROTOCOL_KEY,
174 EVIDENCE_OBJECT_NAME,
175 EVIDENCE_ENABLE_COOKIES,
176 ]),
177 properties: vec![PropertyMetaData::new(
178 JAVASCRIPT_PROPERTY_KEY,
179 JAVASCRIPT_BUILDER_ELEMENT_DATA_KEY,
180 PropertyValueType::String,
181 )],
182 template,
183 host,
184 endpoint,
185 protocol,
186 object_name,
187 enable_cookies,
188 minify,
189 promise_property_available: AtomicBool::new(true),
190 fetch_property_available: AtomicBool::new(true),
191 }
192 }
193
194 fn resolve_protocol(&self, data: &FlowData) -> String {
197 if !self.protocol.is_empty() {
198 return self.protocol.clone();
199 }
200 if let Some(protocol) = data.evidence().get(EVIDENCE_PROTOCOL_KEY) {
201 if !protocol.is_empty() {
202 return protocol.to_owned();
203 }
204 }
205 FALLBACK_PROTOCOL.to_owned()
206 }
207
208 fn resolve_host(&self, data: &FlowData) -> String {
211 if !self.host.is_empty() {
212 return self.host.clone();
213 }
214 data.evidence()
215 .get(EVIDENCE_HOST_KEY)
216 .unwrap_or("")
217 .to_owned()
218 }
219
220 fn resolve_object_name(&self, data: &FlowData) -> String {
223 match data.evidence().get(EVIDENCE_OBJECT_NAME) {
224 Some(name) => name.to_owned(),
225 None => self.object_name.clone(),
226 }
227 }
228
229 fn resolve_enable_cookies(&self, data: &FlowData) -> bool {
233 match data.evidence().get(EVIDENCE_ENABLE_COOKIES) {
234 Some(value) => value.trim().parse::<bool>().unwrap_or(self.enable_cookies),
235 None => self.enable_cookies,
236 }
237 }
238
239 fn build_url(protocol: &str, host: &str, endpoint: &str) -> Option<String> {
246 if protocol.trim().is_empty() || host.trim().is_empty() || endpoint.trim().is_empty() {
247 return None;
248 }
249
250 let endpoint_has_slash = endpoint.starts_with('/');
251 let host_has_slash = host.ends_with('/');
252
253 let normalised_endpoint = if !endpoint_has_slash && !host_has_slash {
254 format!("/{endpoint}")
256 } else if endpoint_has_slash && host_has_slash {
257 endpoint[1..].to_owned()
259 } else {
260 endpoint.to_owned()
261 };
262
263 Some(format!("{protocol}://{host}{normalised_endpoint}"))
264 }
265
266 fn session_id(data: &FlowData) -> String {
268 data.evidence()
269 .get(EVIDENCE_SESSIONID)
270 .unwrap_or("")
271 .to_owned()
272 }
273
274 fn sequence(data: &FlowData) -> i32 {
277 data.evidence()
278 .get(EVIDENCE_SEQUENCE)
279 .and_then(|s| s.trim().parse::<i32>().ok())
280 .unwrap_or(1)
281 }
282
283 fn build_parameters(data: &FlowData) -> String {
291 let query_prefix = format!("{EVIDENCE_QUERY_PREFIX}{EVIDENCE_SEPARATOR}");
292 let mut parameters: BTreeMap<String, String> = BTreeMap::new();
293
294 for (key, value) in data.evidence().iter() {
295 if !key.starts_with(&query_prefix) {
296 continue;
297 }
298 if key.eq_ignore_ascii_case(EVIDENCE_SESSIONID)
300 || key.eq_ignore_ascii_case(EVIDENCE_SEQUENCE)
301 {
302 continue;
303 }
304 let field = &key[query_prefix.len()..];
305 let encoded_key = utf8_percent_encode(field, URL_ENCODE_SET).to_string();
306 let encoded_value = utf8_percent_encode(value, URL_ENCODE_SET).to_string();
307 parameters.insert(encoded_key, encoded_value);
308 }
309
310 serde_json::to_string(¶meters).unwrap_or_else(|_| "{}".to_owned())
311 }
312
313 fn supports_property(
321 &self,
322 latch: &AtomicBool,
323 data: &FlowData,
324 property: &str,
325 predicate: impl Fn(&PropertyValue) -> bool,
326 ) -> bool {
327 if !latch.load(Ordering::Relaxed) {
328 return false;
329 }
330 match data.get_evidence_or_property(property) {
331 Ok(value) => predicate(&value),
332 Err(_) => {
333 latch.store(false, Ordering::Relaxed);
336 false
337 }
338 }
339 }
340
341 fn supports_promises(&self, data: &FlowData) -> bool {
347 self.supports_property(
348 &self.promise_property_available,
349 data,
350 PROMISE_PROPERTY,
351 |value| value.as_str() == Some(PROMISE_FULL_VALUE),
352 )
353 }
354
355 fn supports_fetch(&self, data: &FlowData) -> bool {
361 self.supports_property(
362 &self.fetch_property_available,
363 data,
364 FETCH_PROPERTY,
365 |value| value.as_bool() == Some(true),
366 )
367 }
368
369 fn json_object(data: &FlowData) -> String {
372 data.get(JSON_BUILDER_DATA_KEY)
373 .map(|json| json.json().to_owned())
374 .unwrap_or_default()
375 }
376
377 fn build_javascript(&self, data: &FlowData) -> (String, bool) {
380 let protocol = self.resolve_protocol(data);
381 let host = self.resolve_host(data);
382 let object_name = self.resolve_object_name(data);
383 let enable_cookies = self.resolve_enable_cookies(data);
384
385 let supports_promises = self.supports_promises(data);
386 let supports_fetch = self.supports_fetch(data);
387
388 let json_object = Self::json_object(data);
389 let parameters = Self::build_parameters(data);
390 let session_id = Self::session_id(data);
391 let sequence = Self::sequence(data);
392
393 let url = Self::build_url(&protocol, &host, &self.endpoint);
394 let update_enabled = url.as_ref().is_some_and(|u| !u.is_empty());
395
396 let has_delayed_properties = json_object.contains(DELAY_EXECUTION_MARKER);
397
398 let resource = JavaScriptResource::new(
399 object_name,
400 json_object,
401 session_id,
402 sequence,
403 supports_promises,
404 supports_fetch,
405 url.unwrap_or_default(),
406 parameters,
407 enable_cookies,
408 update_enabled,
409 has_delayed_properties,
410 );
411
412 let content = resource.render(&self.template);
413
414 let outcome = if self.minify {
415 minify(content)
416 } else {
417 crate::minify::MinifyOutcome {
418 content,
419 had_error: false,
420 }
421 };
422
423 (outcome.content, outcome.had_error)
424 }
425}
426
427impl Default for JavaScriptBuilderElement {
428 fn default() -> Self {
429 JavaScriptBuilderElement::new()
430 }
431}
432
433impl FlowElement for JavaScriptBuilderElement {
434 fn process(&self, data: &mut FlowData) -> Result<()> {
435 let (content, _had_error) = self.build_javascript(data);
439
440 let result = data.get_or_add(
441 JAVASCRIPT_BUILDER_DATA_KEY,
442 JavaScriptBuilderElementData::new,
443 );
444 match result {
445 Ok(element_data) => {
446 element_data.set_javascript(content);
447 Ok(())
448 }
449 Err(error) => Err(error),
450 }
451 }
452
453 fn data_key(&self) -> &str {
454 JAVASCRIPT_BUILDER_ELEMENT_DATA_KEY
455 }
456
457 fn evidence_key_filter(&self) -> &dyn EvidenceKeyFilter {
458 &self.evidence_key_filter
459 }
460
461 fn properties(&self) -> &[PropertyMetaData] {
462 &self.properties
463 }
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469 use fiftyone_pipeline_core::{Evidence, Pipeline};
470 use std::sync::Arc;
471
472 fn flow_data_with(pairs: &[(&str, &str)]) -> FlowData {
476 let mut builder = Evidence::builder();
477 for (key, value) in pairs {
478 builder = builder.add(*key, *value);
479 }
480 let pipeline = Pipeline::builder()
481 .add_element(Arc::new(JavaScriptBuilderElement::new()))
482 .build()
483 .expect("pipeline builds");
484 pipeline.create_flow_data_with(builder.build())
485 }
486
487 #[test]
488 fn build_url_adds_single_slash() {
489 let url = JavaScriptBuilderElement::build_url("https", "example.com", "51dpipeline/json");
490 assert_eq!(url.as_deref(), Some("https://example.com/51dpipeline/json"));
491 }
492
493 #[test]
494 fn build_url_keeps_single_slash_on_endpoint() {
495 let url = JavaScriptBuilderElement::build_url("https", "example.com", "/51dpipeline/json");
496 assert_eq!(url.as_deref(), Some("https://example.com/51dpipeline/json"));
497 }
498
499 #[test]
500 fn build_url_collapses_double_slash() {
501 let url = JavaScriptBuilderElement::build_url("http", "example.com/", "/json");
502 assert_eq!(url.as_deref(), Some("http://example.com/json"));
503 }
504
505 #[test]
506 fn build_url_none_when_host_missing() {
507 assert!(JavaScriptBuilderElement::build_url("https", "", "/json").is_none());
508 assert!(JavaScriptBuilderElement::build_url("", "example.com", "/json").is_none());
509 assert!(JavaScriptBuilderElement::build_url("https", "example.com", "").is_none());
510 }
511
512 #[test]
513 fn parameters_exclude_session_and_sequence() {
514 let data = flow_data_with(&[
515 ("query.session-id", "abc"),
516 ("query.sequence", "3"),
517 ("query.user-agent", "test agent"),
518 ("query.fod-js-object-name", "myObj"),
519 ("header.host", "ignored"),
520 ]);
521 let json = JavaScriptBuilderElement::build_parameters(&data);
522 let value: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
523 let object = value.as_object().expect("an object");
524
525 assert!(!object.contains_key("session-id"));
527 assert!(!object.contains_key("sequence"));
528 assert!(!object.values().any(|v| v == "ignored"));
530 assert_eq!(
533 object.get("user-agent").and_then(|v| v.as_str()),
534 Some("test%20agent")
535 );
536 assert_eq!(
537 object.get("fod-js-object-name").and_then(|v| v.as_str()),
538 Some("myObj")
539 );
540 }
541
542 #[test]
543 fn parameters_are_deterministic() {
544 let data = flow_data_with(&[("query.b", "2"), ("query.a", "1"), ("query.c", "3")]);
545 let first = JavaScriptBuilderElement::build_parameters(&data);
546 let second = JavaScriptBuilderElement::build_parameters(&data);
547 assert_eq!(first, second);
548 let a = first.find("\"a\"").unwrap();
550 let b = first.find("\"b\"").unwrap();
551 let c = first.find("\"c\"").unwrap();
552 assert!(a < b && b < c);
553 }
554
555 #[test]
556 fn protocol_falls_back_to_https() {
557 let element = JavaScriptBuilderElement::new();
558 let data = flow_data_with(&[("header.host", "example.com")]);
559 assert_eq!(element.resolve_protocol(&data), "https");
560 }
561
562 #[test]
563 fn protocol_taken_from_evidence_when_not_configured() {
564 let element = JavaScriptBuilderElement::new();
565 let data = flow_data_with(&[("header.protocol", "http")]);
566 assert_eq!(element.resolve_protocol(&data), "http");
567 }
568
569 #[test]
570 fn object_name_overridden_by_evidence() {
571 let element = JavaScriptBuilderElement::new();
572 let data = flow_data_with(&[("query.fod-js-object-name", "custom")]);
573 assert_eq!(element.resolve_object_name(&data), "custom");
574 let data = flow_data_with(&[]);
575 assert_eq!(element.resolve_object_name(&data), "fod");
576 }
577
578 #[test]
579 fn enable_cookies_overridden_by_evidence() {
580 let element = JavaScriptBuilderElement::new();
581 let data = flow_data_with(&[("query.fod-js-enable-cookies", "false")]);
582 assert!(!element.resolve_enable_cookies(&data));
583 let data = flow_data_with(&[]);
585 assert!(element.resolve_enable_cookies(&data));
586 }
587
588 #[test]
589 fn promise_and_fetch_latch_off_when_unavailable() {
590 let element = JavaScriptBuilderElement::new();
591 let data = flow_data_with(&[]);
592 assert!(!element.supports_promises(&data));
594 assert!(!element.supports_fetch(&data));
595 assert!(!element.promise_property_available.load(Ordering::Relaxed));
597 assert!(!element.fetch_property_available.load(Ordering::Relaxed));
598 assert!(!element.supports_promises(&data));
600 assert!(!element.supports_fetch(&data));
601 }
602}