spin_doctor/manifest/
trigger.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
use anyhow::{bail, ensure, Context, Result};
use async_trait::async_trait;
use toml::Value;
use toml_edit::{DocumentMut, InlineTable, Item, Table};

use crate::{Diagnosis, Diagnostic, PatientApp, Treatment};

use super::ManifestTreatment;

/// TriggerDiagnostic detects problems with app trigger config.
#[derive(Default)]
pub struct TriggerDiagnostic;

#[async_trait]
impl Diagnostic for TriggerDiagnostic {
    type Diagnosis = TriggerDiagnosis;

    async fn diagnose(&self, patient: &PatientApp) -> Result<Vec<Self::Diagnosis>> {
        let manifest: toml::Value = toml_edit::de::from_document(patient.manifest_doc.clone())?;

        if manifest.get("spin_manifest_version") == Some(&Value::Integer(2)) {
            // Not applicable to manifest V2
            return Ok(vec![]);
        }

        let mut diags = vec![];

        // Top-level trigger config
        diags.extend(TriggerDiagnosis::for_app_trigger(manifest.get("trigger")));

        // Component-level HTTP trigger config
        let trigger_type = manifest
            .get("trigger")
            .and_then(|item| item.get("type"))
            .and_then(|item| item.as_str());
        if let Some("http") = trigger_type {
            if let Some(Value::Array(components)) = manifest.get("component") {
                let single_component = components.len() == 1;
                for component in components {
                    let id = component
                        .get("id")
                        .and_then(|value| value.as_str())
                        .unwrap_or("<missing ID>")
                        .to_string();
                    diags.extend(TriggerDiagnosis::for_http_component_trigger(
                        id,
                        component.get("trigger"),
                        single_component,
                    ));
                }
            }
        }

        Ok(diags)
    }
}

/// TriggerDiagnosis represents a problem with app trigger config.
#[derive(Debug)]
pub enum TriggerDiagnosis {
    /// Missing app trigger section
    MissingAppTrigger,
    /// Invalid app trigger config
    InvalidAppTrigger(&'static str),
    /// HTTP component trigger missing route field
    HttpComponentTriggerMissingRoute(String, bool),
    /// Invalid HTTP component trigger config
    InvalidHttpComponentTrigger(String, &'static str),
}

impl TriggerDiagnosis {
    fn for_app_trigger(trigger: Option<&Value>) -> Option<Self> {
        let Some(trigger) = trigger else {
            return Some(Self::MissingAppTrigger);
        };
        let Some(trigger) = trigger.as_table() else {
            return Some(Self::InvalidAppTrigger("not a table"));
        };
        let Some(trigger_type) = trigger.get("type") else {
            return Some(Self::InvalidAppTrigger("trigger table missing type"));
        };
        let Some(_) = trigger_type.as_str() else {
            return Some(Self::InvalidAppTrigger("type must be a string"));
        };
        None
    }

    fn for_http_component_trigger(
        id: String,
        trigger: Option<&Value>,
        single_component: bool,
    ) -> Option<Self> {
        let Some(trigger) = trigger else {
            return Some(Self::HttpComponentTriggerMissingRoute(id, single_component));
        };
        let Some(trigger) = trigger.as_table() else {
            return Some(Self::InvalidHttpComponentTrigger(id, "not a table"));
        };
        let Some(route) = trigger.get("route") else {
            return Some(Self::HttpComponentTriggerMissingRoute(id, single_component));
        };
        if route.as_str().is_none() {
            return Some(Self::InvalidHttpComponentTrigger(
                id,
                "route is not a string",
            ));
        }
        None
    }
}

impl Diagnosis for TriggerDiagnosis {
    fn description(&self) -> String {
        match self {
            Self::MissingAppTrigger => "missing top-level trigger config".into(),
            Self::InvalidAppTrigger(msg) => {
                format!("Invalid app trigger config: {msg}")
            }
            Self::HttpComponentTriggerMissingRoute(id, _) => {
                format!("HTTP component {id:?} missing trigger.route")
            }
            Self::InvalidHttpComponentTrigger(id, msg) => {
                format!("Invalid trigger config for http component {id:?}: {msg}")
            }
        }
    }

    fn treatment(&self) -> Option<&dyn Treatment> {
        match self {
            Self::MissingAppTrigger => Some(self),
            // We can reasonably fill in default "route" iff there is only one component
            Self::HttpComponentTriggerMissingRoute(_, single_component) if *single_component => {
                Some(self)
            }
            _ => None,
        }
    }
}

#[async_trait]
impl ManifestTreatment for TriggerDiagnosis {
    fn summary(&self) -> String {
        match self {
            TriggerDiagnosis::MissingAppTrigger => "Add default HTTP trigger config".into(),
            TriggerDiagnosis::HttpComponentTriggerMissingRoute(id, _) => {
                format!("Set trigger.route '/...' for component {id:?}")
            }
            _ => "[invalid treatment]".into(),
        }
    }

    async fn treat_manifest(&self, doc: &mut DocumentMut) -> anyhow::Result<()> {
        match self {
            Self::MissingAppTrigger => {
                // Get or insert missing trigger config
                if doc.get("trigger").is_none() {
                    doc.insert("trigger", Item::Value(InlineTable::new().into()));
                }
                let trigger = doc
                    .get_mut("trigger")
                    .unwrap()
                    .as_table_like_mut()
                    .context("existing trigger value is not a table")?;

                // Get trigger type or insert default "http"
                let trigger_type = trigger.entry("type").or_insert(Item::Value("http".into()));
                if let Some("http") = trigger_type.as_str() {
                    // Strip "type" trailing space
                    if let Some(decor) = trigger_type.as_value_mut().map(|v| v.decor_mut()) {
                        if let Some(suffix) = decor.suffix().and_then(|s| s.as_str()) {
                            decor.set_suffix(suffix.to_string().trim());
                        }
                    }
                }
            }
            Self::HttpComponentTriggerMissingRoute(_, true) => {
                // Get the only component
                let components = doc
                    .get_mut("component")
                    .context("missing components")?
                    .as_array_of_tables_mut()
                    .context("component sections aren't an 'array of tables'")?;
                ensure!(
                    components.len() == 1,
                    "can only set default trigger route if there is exactly one component; found {}",
                    components.len()
                );
                let component = components.get_mut(0).unwrap();

                // Get or insert missing trigger config
                if component.get("trigger").is_none() {
                    component.insert("trigger", Item::Table(Table::new()));
                }
                let trigger = component
                    .get_mut("trigger")
                    .unwrap()
                    .as_table_like_mut()
                    .context("existing trigger value is not a table")?;

                // Set missing "route"
                trigger.entry("route").or_insert(Item::Value("/...".into()));
            }
            _ => bail!("cannot be fixed"),
        }
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use crate::test::{run_broken_test, run_correct_test};

    use super::*;

    #[tokio::test]
    async fn test_correct() {
        run_correct_test::<TriggerDiagnostic>("manifest_trigger").await;
    }

    #[tokio::test]
    async fn test_missing_app_trigger() {
        let diag =
            run_broken_test::<TriggerDiagnostic>("manifest_trigger", "missing_app_trigger").await;
        assert!(matches!(diag, TriggerDiagnosis::MissingAppTrigger));
    }

    #[tokio::test]
    async fn test_http_component_trigger_missing_route() {
        let diag = run_broken_test::<TriggerDiagnostic>(
            "manifest_trigger",
            "http_component_trigger_missing_route",
        )
        .await;
        assert!(matches!(
            diag,
            TriggerDiagnosis::HttpComponentTriggerMissingRoute(_, _)
        ));
    }
}