Skip to main content

cloudconvert_sdk/
operations.rs

1//! CloudConvert operations metadata and local task validation helpers.
2//!
3//! [`Operation`] records describe supported engines, formats, and documented
4//! options returned by `GET /v2/operations`. [`Operation::validate_task`]
5//! checks a [`TaskRequest`] against that metadata before sending it to the API.
6
7use 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/// Query parameters for `GET /v2/operations`.
18#[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/// One supported CloudConvert operation with engines, options, and alternatives.
109#[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    /// Validates a task against this operation record, ignoring unknown options.
169    pub fn validate_task(&self, task: &TaskRequest) -> OperationValidationResult {
170        self.validate_task_with_mode(task, OperationValidationMode::Lenient)
171    }
172
173    /// Validates a task and rejects options not documented for this operation.
174    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/// Documented option schema for a CloudConvert operation.
250#[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/// Declared value type for an operation option in metadata responses.
331#[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/// Engine version entry attached to an [`Operation`] metadata record.
404#[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/// Controls whether undocumented task options are accepted during validation.
421#[derive(Clone, Copy, Debug, Eq, PartialEq)]
422#[non_exhaustive]
423pub enum OperationValidationMode {
424    Lenient,
425    Strict,
426}
427
428/// Result of validating a [`TaskRequest`] against an [`Operation`] record.
429pub type OperationValidationResult = std::result::Result<(), OperationValidationError>;
430
431/// Validation failure describing which operation field or option was rejected.
432#[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/// Specific validation failure category for an operation or option check.
528#[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}