1use std::collections::BTreeMap;
8
9use std::{error::Error as StdError, fmt};
10
11use serde::{Deserialize, Deserializer, Serialize, Serializer};
12use serde_json::Value;
13
14use crate::file_extension::normalize_file_extension;
15use crate::tasks::TaskRequest;
16
17#[derive(Clone, Debug, Default, Serialize)]
19pub struct OperationListQuery {
20 #[serde(rename = "filter[operation]", skip_serializing_if = "Option::is_none")]
21 operation: Option<String>,
22 #[serde(
23 rename = "filter[input_format]",
24 skip_serializing_if = "Option::is_none"
25 )]
26 input_format: Option<String>,
27 #[serde(
28 rename = "filter[output_format]",
29 skip_serializing_if = "Option::is_none"
30 )]
31 output_format: Option<String>,
32 #[serde(rename = "filter[engine]", skip_serializing_if = "Option::is_none")]
33 engine: Option<String>,
34 #[serde(
35 rename = "filter[engine_version]",
36 skip_serializing_if = "Option::is_none"
37 )]
38 engine_version: Option<String>,
39 #[serde(skip_serializing_if = "Option::is_none")]
40 include: Option<String>,
41 #[serde(skip_serializing_if = "Option::is_none")]
42 alternatives: Option<bool>,
43 #[serde(skip_serializing_if = "Option::is_none")]
44 page: Option<u32>,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 per_page: Option<u32>,
47}
48
49impl OperationListQuery {
50 pub fn operation(mut self, operation: impl Into<String>) -> Self {
51 self.operation = Some(operation.into());
52 self
53 }
54
55 pub fn input_format(mut self, input_format: impl Into<String>) -> Self {
56 self.input_format = Some(normalize_file_extension(input_format));
57 self
58 }
59
60 pub fn output_format(mut self, output_format: impl Into<String>) -> Self {
61 self.output_format = Some(normalize_file_extension(output_format));
62 self
63 }
64
65 pub fn engine(mut self, engine: impl Into<String>) -> Self {
66 self.engine = Some(engine.into());
67 self
68 }
69
70 pub fn engine_version(mut self, engine_version: impl Into<String>) -> Self {
71 self.engine_version = Some(engine_version.into());
72 self
73 }
74
75 pub fn include(mut self, include: impl Into<String>) -> Self {
76 self.include = Some(include.into());
77 self
78 }
79
80 pub fn include_options(self) -> Self {
81 self.include("options")
82 }
83
84 pub fn include_engine_versions(self) -> Self {
85 self.include("engine_versions")
86 }
87
88 pub fn include_options_and_engine_versions(self) -> Self {
89 self.include("options,engine_versions")
90 }
91
92 pub fn alternatives(mut self, alternatives: bool) -> Self {
93 self.alternatives = Some(alternatives);
94 self
95 }
96
97 pub fn page(mut self, page: u32) -> Self {
98 self.page = Some(page);
99 self
100 }
101
102 pub fn per_page(mut self, per_page: u32) -> Self {
103 self.per_page = Some(per_page);
104 self
105 }
106}
107
108#[derive(Clone, Debug, Deserialize, Serialize)]
110#[non_exhaustive]
111pub struct Operation {
112 pub operation: String,
113 #[serde(default)]
114 pub input_format: Option<String>,
115 #[serde(default)]
116 pub output_format: Option<String>,
117 #[serde(default)]
118 pub engine: Option<String>,
119 #[serde(default)]
120 pub engine_version: Option<String>,
121 #[serde(default)]
122 pub credits: Option<u64>,
123 #[serde(default, deserialize_with = "deserialize_options")]
124 pub options: BTreeMap<String, OperationOption>,
125 #[serde(default, deserialize_with = "deserialize_engine_versions")]
126 pub engine_versions: Vec<OperationEngineVersion>,
127 #[serde(default)]
128 pub alternatives: Vec<Operation>,
129 #[serde(default)]
130 pub deprecated: Option<bool>,
131 #[serde(default)]
132 pub experimental: Option<bool>,
133 #[serde(default)]
134 pub meta: Option<BTreeMap<String, Value>>,
135 #[serde(flatten)]
136 pub extra: BTreeMap<String, Value>,
137}
138
139impl Operation {
140 pub fn option(&self, name: &str) -> Option<&OperationOption> {
141 self.options.get(name)
142 }
143
144 pub fn options(&self) -> impl Iterator<Item = (&str, &OperationOption)> {
145 self.options
146 .iter()
147 .map(|(name, option)| (name.as_str(), option))
148 }
149
150 pub fn engine_version_values(&self) -> impl Iterator<Item = &str> {
151 self.engine_versions
152 .iter()
153 .map(|version| version.version.as_str())
154 }
155
156 pub fn default_engine_version(&self) -> Option<&OperationEngineVersion> {
157 self.engine_versions
158 .iter()
159 .find(|version| version.default.unwrap_or(false))
160 }
161
162 pub fn latest_engine_version(&self) -> Option<&OperationEngineVersion> {
163 self.engine_versions
164 .iter()
165 .find(|version| version.latest.unwrap_or(false))
166 }
167
168 pub fn validate_task(&self, task: &TaskRequest) -> OperationValidationResult {
170 self.validate_task_with_mode(task, OperationValidationMode::Lenient)
171 }
172
173 pub fn validate_task_strict(&self, task: &TaskRequest) -> OperationValidationResult {
175 self.validate_task_with_mode(task, OperationValidationMode::Strict)
176 }
177
178 fn check_identity_field(
179 &self,
180 task: &TaskRequest,
181 field: &str,
182 expected: &Option<String>,
183 ) -> OperationValidationResult {
184 if let (Some(expected), Some(actual)) = (
185 expected.as_deref(),
186 task.payload().get(field).and_then(Value::as_str),
187 ) {
188 if expected != actual {
189 return Err(OperationValidationError::format_mismatch(
190 &self.operation,
191 field,
192 expected,
193 actual,
194 ));
195 }
196 }
197 Ok(())
198 }
199
200 pub fn validate_task_with_mode(
201 &self,
202 task: &TaskRequest,
203 mode: OperationValidationMode,
204 ) -> OperationValidationResult {
205 if task.operation() != self.operation {
206 return Err(OperationValidationError::operation_mismatch(
207 &self.operation,
208 task.operation(),
209 ));
210 }
211
212 self.check_identity_field(task, "input_format", &self.input_format)?;
213 self.check_identity_field(task, "output_format", &self.output_format)?;
214 self.check_identity_field(task, "engine", &self.engine)?;
215 self.check_identity_field(task, "engine_version", &self.engine_version)?;
216
217 for (name, option) in &self.options {
218 let value = task.payload().get(name);
219 if option.required.unwrap_or(false) && value.is_none_or(Value::is_null) {
220 return Err(OperationValidationError::missing_required(
221 &self.operation,
222 name,
223 ));
224 }
225
226 let Some(value) = value else {
227 continue;
228 };
229 option.validate_value_for_operation(&self.operation, name, value)?;
230 }
231
232 if matches!(mode, OperationValidationMode::Strict) && !self.options.is_empty() {
233 for name in task.payload().keys() {
234 if !self.options.contains_key(name)
235 && !is_structural_task_field(&self.operation, name)
236 {
237 return Err(OperationValidationError::unknown_option(
238 &self.operation,
239 name,
240 ));
241 }
242 }
243 }
244
245 Ok(())
246 }
247}
248
249#[derive(Clone, Debug, Deserialize, Serialize)]
251#[non_exhaustive]
252pub struct OperationOption {
253 #[serde(default)]
254 pub name: Option<String>,
255 #[serde(default, rename = "type")]
256 pub kind: Option<OperationOptionKind>,
257 #[serde(default)]
258 pub label: Option<String>,
259 #[serde(default)]
260 pub description: Option<String>,
261 #[serde(default)]
262 pub required: Option<bool>,
263 #[serde(default)]
264 pub default: Option<Value>,
265 #[serde(default, alias = "values")]
266 pub possible_values: Vec<Value>,
267 #[serde(flatten)]
268 pub extra: BTreeMap<String, Value>,
269}
270
271impl OperationOption {
272 pub fn name(&self) -> Option<&str> {
273 self.name.as_deref()
274 }
275
276 pub fn kind(&self) -> Option<&OperationOptionKind> {
277 self.kind.as_ref()
278 }
279
280 pub fn is_required(&self) -> bool {
281 self.required.unwrap_or(false)
282 }
283
284 pub fn possible_values(&self) -> &[Value] {
285 &self.possible_values
286 }
287
288 pub fn validate_value(&self, value: &Value) -> OperationValidationResult {
289 self.validate_value_for_operation("", self.name.as_deref().unwrap_or("option"), value)
290 }
291
292 fn validate_value_for_operation(
293 &self,
294 operation: &str,
295 name: &str,
296 value: &Value,
297 ) -> OperationValidationResult {
298 if let Some(kind) = &self.kind
299 && !kind.matches_value(value)
300 {
301 return Err(OperationValidationError::invalid_type(
302 operation,
303 name,
304 kind.as_str(),
305 value_type(value),
306 ));
307 }
308
309 if !self.possible_values.is_empty() {
310 let allowed = match (&self.kind, value) {
311 (Some(OperationOptionKind::Array), Value::Array(items)) => items
312 .iter()
313 .all(|item| self.possible_values.iter().any(|allowed| allowed == item)),
314 _ => self.possible_values.iter().any(|allowed| allowed == value),
315 };
316 if !allowed {
317 return Err(OperationValidationError::invalid_value(
318 operation,
319 name,
320 "one of the documented possible_values",
321 value,
322 ));
323 }
324 }
325
326 Ok(())
327 }
328}
329
330#[derive(Clone, Debug, Eq, PartialEq)]
332#[non_exhaustive]
333pub enum OperationOptionKind {
334 String,
335 Boolean,
336 Integer,
337 Float,
338 Enum,
339 Dictionary,
340 Array,
341 Other(String),
342}
343
344impl OperationOptionKind {
345 pub fn as_str(&self) -> &str {
346 match self {
347 Self::String => "string",
348 Self::Boolean => "boolean",
349 Self::Integer => "integer",
350 Self::Float => "float",
351 Self::Enum => "enum",
352 Self::Dictionary => "dictionary",
353 Self::Array => "array",
354 Self::Other(value) => value.as_str(),
355 }
356 }
357
358 fn matches_value(&self, value: &Value) -> bool {
359 match self {
360 Self::String => value.is_string(),
361 Self::Boolean => value.is_boolean(),
362 Self::Integer => value
363 .as_i64()
364 .or_else(|| value.as_u64().and_then(|value| i64::try_from(value).ok()))
365 .is_some(),
366 Self::Float => value.is_number(),
367 Self::Enum => value.is_string(),
368 Self::Dictionary => value.is_object(),
369 Self::Array => value.is_array(),
370 Self::Other(_) => true,
371 }
372 }
373}
374
375impl Serialize for OperationOptionKind {
376 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
377 where
378 S: Serializer,
379 {
380 serializer.serialize_str(self.as_str())
381 }
382}
383
384impl<'de> Deserialize<'de> for OperationOptionKind {
385 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
386 where
387 D: Deserializer<'de>,
388 {
389 let value = String::deserialize(deserializer)?;
390 Ok(match value.as_str() {
391 "string" => Self::String,
392 "boolean" => Self::Boolean,
393 "integer" => Self::Integer,
394 "float" => Self::Float,
395 "enum" => Self::Enum,
396 "dictionary" => Self::Dictionary,
397 "array" => Self::Array,
398 _ => Self::Other(value),
399 })
400 }
401}
402
403#[derive(Clone, Debug, Deserialize, Serialize)]
405#[non_exhaustive]
406pub struct OperationEngineVersion {
407 pub version: String,
408 #[serde(default)]
409 pub default: Option<bool>,
410 #[serde(default)]
411 pub latest: Option<bool>,
412 #[serde(default)]
413 pub deprecated: Option<bool>,
414 #[serde(default)]
415 pub experimental: Option<bool>,
416 #[serde(flatten)]
417 pub extra: BTreeMap<String, Value>,
418}
419
420#[derive(Clone, Copy, Debug, Eq, PartialEq)]
422#[non_exhaustive]
423pub enum OperationValidationMode {
424 Lenient,
425 Strict,
426}
427
428pub type OperationValidationResult = std::result::Result<(), OperationValidationError>;
430
431#[derive(Clone, Debug, Eq, PartialEq)]
433pub struct OperationValidationError {
434 pub kind: OperationValidationErrorKind,
435 pub operation: String,
436 pub option: Option<String>,
437 pub expected: Option<String>,
438 pub actual: Option<String>,
439}
440
441impl OperationValidationError {
442 fn operation_mismatch(expected: &str, actual: &str) -> Self {
443 Self {
444 kind: OperationValidationErrorKind::OperationMismatch,
445 operation: expected.to_string(),
446 option: None,
447 expected: Some(expected.to_string()),
448 actual: Some(actual.to_string()),
449 }
450 }
451
452 fn format_mismatch(operation: &str, field: &str, expected: &str, actual: &str) -> Self {
453 Self {
454 kind: OperationValidationErrorKind::FormatMismatch,
455 operation: operation.to_string(),
456 option: Some(field.to_string()),
457 expected: Some(expected.to_string()),
458 actual: Some(actual.to_string()),
459 }
460 }
461
462 fn missing_required(operation: &str, option: &str) -> Self {
463 Self {
464 kind: OperationValidationErrorKind::MissingRequiredOption,
465 operation: operation.to_string(),
466 option: Some(option.to_string()),
467 expected: Some("present value".to_string()),
468 actual: Some("missing".to_string()),
469 }
470 }
471
472 fn invalid_type(operation: &str, option: &str, expected: &str, actual: &str) -> Self {
473 Self {
474 kind: OperationValidationErrorKind::InvalidOptionType,
475 operation: operation.to_string(),
476 option: Some(option.to_string()),
477 expected: Some(expected.to_string()),
478 actual: Some(actual.to_string()),
479 }
480 }
481
482 fn invalid_value(operation: &str, option: &str, expected: &str, actual: &Value) -> Self {
483 Self {
484 kind: OperationValidationErrorKind::InvalidOptionValue,
485 operation: operation.to_string(),
486 option: Some(option.to_string()),
487 expected: Some(expected.to_string()),
488 actual: Some(actual.to_string()),
489 }
490 }
491
492 fn unknown_option(operation: &str, option: &str) -> Self {
493 Self {
494 kind: OperationValidationErrorKind::UnknownOption,
495 operation: operation.to_string(),
496 option: Some(option.to_string()),
497 expected: Some("documented operation option".to_string()),
498 actual: Some("unknown option".to_string()),
499 }
500 }
501}
502
503impl fmt::Display for OperationValidationError {
504 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
505 match &self.option {
506 Some(option) => write!(
507 formatter,
508 "operation {} option {} failed validation: expected {}, got {}",
509 self.operation,
510 option,
511 self.expected.as_deref().unwrap_or("valid value"),
512 self.actual.as_deref().unwrap_or("invalid value")
513 ),
514 None => write!(
515 formatter,
516 "operation {} failed validation: expected {}, got {}",
517 self.operation,
518 self.expected.as_deref().unwrap_or("valid value"),
519 self.actual.as_deref().unwrap_or("invalid value")
520 ),
521 }
522 }
523}
524
525impl StdError for OperationValidationError {}
526
527#[derive(Clone, Debug, Eq, PartialEq)]
529#[non_exhaustive]
530pub enum OperationValidationErrorKind {
531 OperationMismatch,
532 FormatMismatch,
533 MissingRequiredOption,
534 InvalidOptionType,
535 InvalidOptionValue,
536 UnknownOption,
537}
538
539#[derive(Deserialize)]
540#[serde(untagged)]
541enum OperationOptionsWire {
542 Map(BTreeMap<String, OperationOption>),
543 List(Vec<OperationOption>),
544}
545
546fn deserialize_options<'de, D>(
547 deserializer: D,
548) -> std::result::Result<BTreeMap<String, OperationOption>, D::Error>
549where
550 D: Deserializer<'de>,
551{
552 let wire = Option::<OperationOptionsWire>::deserialize(deserializer)?;
553 let mut options = BTreeMap::new();
554
555 match wire {
556 Some(OperationOptionsWire::Map(map)) => {
557 for (name, mut option) in map {
558 if option.name.is_none() {
559 option.name = Some(name.clone());
560 }
561 options.insert(name, option);
562 }
563 }
564 Some(OperationOptionsWire::List(list)) => {
565 for option in list {
566 if let Some(name) = option.name.clone() {
567 options.insert(name, option);
568 }
569 }
570 }
571 None => {}
572 }
573
574 Ok(options)
575}
576
577#[derive(Deserialize)]
578#[serde(untagged)]
579enum OperationEngineVersionWire {
580 String(String),
581 Object(OperationEngineVersion),
582}
583
584fn deserialize_engine_versions<'de, D>(
585 deserializer: D,
586) -> std::result::Result<Vec<OperationEngineVersion>, D::Error>
587where
588 D: Deserializer<'de>,
589{
590 let wire = Option::<Vec<OperationEngineVersionWire>>::deserialize(deserializer)?;
591 Ok(wire
592 .unwrap_or_default()
593 .into_iter()
594 .map(|version| match version {
595 OperationEngineVersionWire::String(version) => OperationEngineVersion {
596 version,
597 default: None,
598 latest: None,
599 deprecated: None,
600 experimental: None,
601 extra: BTreeMap::new(),
602 },
603 OperationEngineVersionWire::Object(version) => version,
604 })
605 .collect())
606}
607
608fn is_structural_task_field(operation: &str, name: &str) -> bool {
609 if matches!(name, "ignore_error") {
610 return true;
611 }
612
613 match operation {
614 "import/url" => matches!(name, "url" | "filename" | "headers"),
615 "import/upload" => matches!(name, "redirect"),
616 "import/base64" | "import/raw" => matches!(name, "file" | "filename"),
617 "import/s3" => matches!(
618 name,
619 "bucket"
620 | "region"
621 | "endpoint"
622 | "key"
623 | "key_prefix"
624 | "access_key_id"
625 | "secret_access_key"
626 | "session_token"
627 | "filename"
628 ),
629 "import/azure/blob" => matches!(
630 name,
631 "storage_account"
632 | "storage_access_key"
633 | "sas_token"
634 | "container"
635 | "blob"
636 | "blob_prefix"
637 | "filename"
638 ),
639 "import/google-cloud-storage" => matches!(
640 name,
641 "project_id"
642 | "bucket"
643 | "client_email"
644 | "private_key"
645 | "file"
646 | "file_prefix"
647 | "filename"
648 ),
649 "import/openstack" => matches!(
650 name,
651 "auth_url"
652 | "username"
653 | "password"
654 | "region"
655 | "container"
656 | "file"
657 | "file_prefix"
658 | "filename"
659 ),
660 "import/sftp" => matches!(
661 name,
662 "host"
663 | "port"
664 | "username"
665 | "password"
666 | "private_key"
667 | "file"
668 | "path"
669 | "filename"
670 ),
671 "convert" => is_file_processing_field(name),
672 "optimize" | "metadata" => matches!(
673 name,
674 "input" | "input_format" | "engine" | "engine_version" | "filename" | "timeout"
675 ),
676 "watermark" => matches!(
677 name,
678 "input" | "input_format" | "engine" | "engine_version" | "filename" | "timeout"
679 ),
680 "capture-website" => matches!(
681 name,
682 "url" | "output_format" | "engine" | "engine_version" | "filename" | "timeout"
683 ),
684 "thumbnail" => is_file_processing_field(name),
685 "metadata/write" => matches!(
686 name,
687 "input"
688 | "input_format"
689 | "engine"
690 | "engine_version"
691 | "metadata"
692 | "filename"
693 | "timeout"
694 ),
695 "merge" | "archive" => matches!(
696 name,
697 "input" | "output_format" | "engine" | "engine_version" | "filename" | "timeout"
698 ),
699 "command" => matches!(
700 name,
701 "input"
702 | "engine"
703 | "command"
704 | "arguments"
705 | "engine_version"
706 | "capture_output"
707 | "timeout"
708 ),
709 "pdf/a" | "pdf/x" | "pdf/ocr" | "pdf/encrypt" | "pdf/decrypt" | "pdf/split-pages"
710 | "pdf/extract-pages" | "pdf/rotate-pages" => matches!(
711 name,
712 "input" | "engine" | "engine_version" | "filename" | "timeout"
713 ),
714 "export/url" => matches!(name, "input" | "inline" | "archive_multiple_files"),
715 "export/s3" => matches!(
716 name,
717 "input"
718 | "bucket"
719 | "region"
720 | "endpoint"
721 | "key"
722 | "key_prefix"
723 | "access_key_id"
724 | "secret_access_key"
725 | "session_token"
726 ),
727 "export/azure/blob" => matches!(
728 name,
729 "input"
730 | "storage_account"
731 | "storage_access_key"
732 | "sas_token"
733 | "container"
734 | "blob"
735 | "blob_prefix"
736 ),
737 "export/google-cloud-storage" => matches!(
738 name,
739 "input"
740 | "project_id"
741 | "bucket"
742 | "client_email"
743 | "private_key"
744 | "file"
745 | "file_prefix"
746 ),
747 "export/openstack" => matches!(
748 name,
749 "input"
750 | "auth_url"
751 | "username"
752 | "password"
753 | "region"
754 | "container"
755 | "file"
756 | "file_prefix"
757 ),
758 "export/sftp" => matches!(
759 name,
760 "input" | "host" | "port" | "username" | "password" | "private_key" | "file" | "path"
761 ),
762 "export/upload" => matches!(name, "input" | "url" | "headers"),
763 _ => matches!(
764 name,
765 "input"
766 | "input_format"
767 | "output_format"
768 | "engine"
769 | "engine_version"
770 | "filename"
771 | "timeout"
772 ),
773 }
774}
775
776fn is_file_processing_field(name: &str) -> bool {
777 matches!(
778 name,
779 "input"
780 | "input_format"
781 | "output_format"
782 | "engine"
783 | "engine_version"
784 | "filename"
785 | "timeout"
786 )
787}
788
789fn value_type(value: &Value) -> &'static str {
790 match value {
791 Value::Null => "null",
792 Value::Bool(_) => "boolean",
793 Value::Number(number) if number.is_i64() || number.is_u64() => "integer",
794 Value::Number(_) => "float",
795 Value::String(_) => "string",
796 Value::Array(_) => "array",
797 Value::Object(_) => "dictionary",
798 }
799}
800
801#[cfg(test)]
802mod tests {
803 use super::*;
804
805 #[test]
806 fn structural_task_fields_are_operation_specific() {
807 const FILE_PROCESSING_FIELDS: &[&str] = &[
808 "input",
809 "input_format",
810 "output_format",
811 "engine",
812 "engine_version",
813 "filename",
814 "timeout",
815 ];
816 const INPUT_PROCESSING_FIELDS: &[&str] = &[
817 "input",
818 "input_format",
819 "engine",
820 "engine_version",
821 "filename",
822 "timeout",
823 ];
824 const OUTPUT_PROCESSING_FIELDS: &[&str] = &[
825 "input",
826 "output_format",
827 "engine",
828 "engine_version",
829 "filename",
830 "timeout",
831 ];
832 const PDF_FIELDS: &[&str] = &["input", "engine", "engine_version", "filename", "timeout"];
833
834 let cases: &[(&str, &[&str])] = &[
835 ("import/url", &["url", "filename", "headers"]),
836 ("import/upload", &["redirect"]),
837 ("import/base64", &["file", "filename"]),
838 ("import/raw", &["file", "filename"]),
839 (
840 "import/s3",
841 &[
842 "bucket",
843 "region",
844 "endpoint",
845 "key",
846 "key_prefix",
847 "access_key_id",
848 "secret_access_key",
849 "session_token",
850 "filename",
851 ],
852 ),
853 (
854 "import/azure/blob",
855 &[
856 "storage_account",
857 "storage_access_key",
858 "sas_token",
859 "container",
860 "blob",
861 "blob_prefix",
862 "filename",
863 ],
864 ),
865 (
866 "import/google-cloud-storage",
867 &[
868 "project_id",
869 "bucket",
870 "client_email",
871 "private_key",
872 "file",
873 "file_prefix",
874 "filename",
875 ],
876 ),
877 (
878 "import/openstack",
879 &[
880 "auth_url",
881 "username",
882 "password",
883 "region",
884 "container",
885 "file",
886 "file_prefix",
887 "filename",
888 ],
889 ),
890 (
891 "import/sftp",
892 &[
893 "host",
894 "port",
895 "username",
896 "password",
897 "private_key",
898 "file",
899 "path",
900 "filename",
901 ],
902 ),
903 ("convert", FILE_PROCESSING_FIELDS),
904 ("optimize", INPUT_PROCESSING_FIELDS),
905 ("metadata", INPUT_PROCESSING_FIELDS),
906 ("watermark", INPUT_PROCESSING_FIELDS),
907 (
908 "capture-website",
909 &[
910 "url",
911 "output_format",
912 "engine",
913 "engine_version",
914 "filename",
915 "timeout",
916 ],
917 ),
918 ("thumbnail", FILE_PROCESSING_FIELDS),
919 (
920 "metadata/write",
921 &[
922 "input",
923 "input_format",
924 "engine",
925 "engine_version",
926 "metadata",
927 "filename",
928 "timeout",
929 ],
930 ),
931 ("merge", OUTPUT_PROCESSING_FIELDS),
932 ("archive", OUTPUT_PROCESSING_FIELDS),
933 (
934 "command",
935 &[
936 "input",
937 "engine",
938 "command",
939 "arguments",
940 "engine_version",
941 "capture_output",
942 "timeout",
943 ],
944 ),
945 ("pdf/a", PDF_FIELDS),
946 ("pdf/x", PDF_FIELDS),
947 ("pdf/ocr", PDF_FIELDS),
948 ("pdf/encrypt", PDF_FIELDS),
949 ("pdf/decrypt", PDF_FIELDS),
950 ("pdf/split-pages", PDF_FIELDS),
951 ("pdf/extract-pages", PDF_FIELDS),
952 ("pdf/rotate-pages", PDF_FIELDS),
953 ("export/url", &["input", "inline", "archive_multiple_files"]),
954 (
955 "export/s3",
956 &[
957 "input",
958 "bucket",
959 "region",
960 "endpoint",
961 "key",
962 "key_prefix",
963 "access_key_id",
964 "secret_access_key",
965 "session_token",
966 ],
967 ),
968 (
969 "export/azure/blob",
970 &[
971 "input",
972 "storage_account",
973 "storage_access_key",
974 "sas_token",
975 "container",
976 "blob",
977 "blob_prefix",
978 ],
979 ),
980 (
981 "export/google-cloud-storage",
982 &[
983 "input",
984 "project_id",
985 "bucket",
986 "client_email",
987 "private_key",
988 "file",
989 "file_prefix",
990 ],
991 ),
992 (
993 "export/openstack",
994 &[
995 "input",
996 "auth_url",
997 "username",
998 "password",
999 "region",
1000 "container",
1001 "file",
1002 "file_prefix",
1003 ],
1004 ),
1005 (
1006 "export/sftp",
1007 &[
1008 "input",
1009 "host",
1010 "port",
1011 "username",
1012 "password",
1013 "private_key",
1014 "file",
1015 "path",
1016 ],
1017 ),
1018 ("export/upload", &["input", "url", "headers"]),
1019 ("future/custom-op", FILE_PROCESSING_FIELDS),
1020 ];
1021
1022 for &(operation, fields) in cases {
1023 assert!(is_structural_task_field(operation, "ignore_error"));
1024 for &field in fields {
1025 assert!(
1026 is_structural_task_field(operation, field),
1027 "{operation} should accept structural field {field}"
1028 );
1029 }
1030 }
1031
1032 for &(operation, field) in &[
1033 ("convert", "url"),
1034 ("convert", "bucket"),
1035 ("import/url", "bucket"),
1036 ("export/s3", "url"),
1037 ("future/custom-op", "url"),
1038 ("future/custom-op", "not_a_real_field"),
1039 ] {
1040 assert!(
1041 !is_structural_task_field(operation, field),
1042 "{operation} should reject unrelated field {field}"
1043 );
1044 }
1045 }
1046}