aviso 2.0.0

Core client library for aviso-server, ECMWF's notification service.
Documentation
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
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
// (C) Copyright 2024- ECMWF and individual contributors.
//
// This software is licensed under the terms of the Apache Licence Version 2.0
// which can be obtained at http://www.apache.org/licenses/LICENSE-2.0.
// In applying this licence, ECMWF does not waive the privileges and immunities
// granted to it by virtue of its status as an intergovernmental organisation nor
// does it submit to any jurisdiction.

//! Tiny in-crate template engine used by command and (later) webhook
//! triggers.
//!
//! Two namespaces are recognised inside `{{ ... }}` expressions:
//!
//! - `{{ notification.<dotted.path> }}`: resolves into the notification's
//!   serialised JSON value via a top-down walk of object keys. Empty
//!   path means the whole notification.
//! - `{{ env.<NAME> }}`: resolves into `std::env::var(NAME)`.
//!
//! Literal `{{` is escaped as `\{{`.
//!
//! # Resolution rules
//!
//! - Scalar string: rendered UNQUOTED (the inner string only). Both
//!   `serde_json::to_string` and `Value::Display` would add quotes;
//!   we destructure the `Value::String(s)` arm to avoid them.
//! - Scalar number / bool: rendered via `Value::to_string()` which gives
//!   `"42"` / `"true"` / `"false"` / `"3.14"` without quotes.
//! - `null`: rendered as the literal four-character string `null`.
//! - Object / Array: rendered as compact JSON via `serde_json::to_string`.
//!   This is the useful answer for `{{ notification.identifier }}` and
//!   `{{ notification.payload }}` whole-blob dumps.
//! - Missing path (any `Value::get` returns `None`): [`TemplateErrorKind::Missing`].
//! - Missing env var (`std::env::var` returns `Err(NotPresent)`):
//!   [`TemplateErrorKind::EnvNotSet`].
//! - `BadSyntax` from [`compile`]: unclosed `{{`, empty path segment in
//!   `notification.a..b`, unknown namespace (not `notification` or `env`),
//!   or escape character used inside an expression.
//!
//! # Public vs crate-private surface
//!
//! [`TemplateErrorKind`] is public because it appears as a field of the
//! public [`crate::watch::TriggerError::Template`] variant. The
//! crate-private [`TemplateError`] is a carrier struct used internally;
//! [`template_error_to_trigger_error`] converts it to the public
//! variant at the dispatch boundary, emitting a DEBUG tracing event
//! with the raw template (which never reaches the public error).

use crate::Notification;

#[cfg(test)]
mod tests;

/// Categorises a template-engine failure. Public because it appears as
/// the `kind` field of [`crate::watch::TriggerError::Template`].
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TemplateErrorKind {
    /// A `{{ notification.<path> }}` expression resolved to no value
    /// (one of the path segments did not exist on the notification JSON).
    Missing,
    /// A `{{ env.<NAME> }}` expression's environment variable was not
    /// set in the process environment.
    EnvNotSet,
    /// A `{{ env.<NAME> }}` expression's environment variable WAS set
    /// but contained bytes that are not valid UTF-8. Distinct from
    /// `EnvNotSet` because the operator's diagnosis differs: a
    /// not-set variable means a misconfigured deployment, while a
    /// not-unicode variable means the value itself needs fixing.
    EnvNotUnicode,
    /// The template source itself was malformed. The accompanying
    /// `field` on [`crate::watch::TriggerError::Template`] names the
    /// parse failure category, NOT a snippet of the raw template, so
    /// it is safe to surface even when the template contains secrets.
    BadSyntax,
    /// The notification could not be serialised to JSON. Practically
    /// unreachable given the well-typed [`crate::Notification`]
    /// shape (every field is a concrete scalar or `serde_json::Value`
    /// that already round-trips through `serde_json::to_value`), but
    /// kept as a distinct kind so the operator's diagnosis points at
    /// the notification itself rather than chasing a missing-path
    /// template bug.
    NotificationEncode,
}

/// Crate-private carrier returned by the template engine. The raw
/// template is retained for DEBUG-level tracing and never reaches the
/// public [`crate::watch::TriggerError::Template`] variant.
///
/// `Clone` is required because [`CompiledTemplate`] is cloneable and
/// dispatchers may store the compile result by value.
#[derive(Debug, Clone)]
pub(crate) struct TemplateError {
    /// The original template source. Useful for DEBUG logging; not
    /// surfaced in public errors.
    pub raw_template: String,
    /// What failed: a JSON path (`"notification.payload.target"`), an
    /// env-var name (`"SLACK_TOKEN"`), or a safe static label for
    /// `BadSyntax` (`"unclosed_braces"`, `"empty_path_segment"`, etc.).
    pub field: String,
    /// Categorisation of the failure.
    pub kind: TemplateErrorKind,
}

/// A template parsed into segments and ready to render. Constructed via
/// [`compile`] and rendered via [`CompiledTemplate::render`].
#[derive(Debug, Clone)]
pub(crate) struct CompiledTemplate {
    raw: String,
    segments: Vec<Segment>,
}

#[derive(Debug, Clone)]
enum Segment {
    /// A literal piece of the template (the text outside `{{ ... }}`
    /// pairs, with `\{{` escapes already converted to literal `{{`).
    Literal(String),
    /// `{{ notification }}` or `{{ notification.a.b.c }}`. Empty `path`
    /// means the whole notification.
    NotificationPath(Vec<String>),
    /// `{{ env.NAME }}`.
    EnvVar(String),
}

/// Parses a template source into a [`CompiledTemplate`].
///
/// Returns [`TemplateError`] with `kind = TemplateErrorKind::BadSyntax`
/// when the template is malformed. The `field` is a safe static label
/// naming the parse failure (`"unclosed_braces"`, `"empty_path_segment"`,
/// `"unknown_namespace"`, `"escape_inside_expr"`).
pub(crate) fn compile(template: &str) -> Result<CompiledTemplate, TemplateError> {
    let mut segments: Vec<Segment> = Vec::new();
    let mut literal_buf = String::new();
    let mut chars = template.char_indices().peekable();

    while let Some((_, ch)) = chars.next() {
        if ch == '\\' {
            // `\{{` escape: emit literal `{{` and skip both braces.
            if let Some(&(_, next1)) = chars.peek() {
                if next1 == '{' {
                    chars.next();
                    if let Some(&(_, next2)) = chars.peek() {
                        if next2 == '{' {
                            chars.next();
                            literal_buf.push_str("{{");
                            continue;
                        }
                    }
                    // `\{` not followed by another `{`: treat as literal `\` and `{`.
                    literal_buf.push('\\');
                    literal_buf.push('{');
                    continue;
                }
            }
            literal_buf.push('\\');
            continue;
        }

        if ch == '{' {
            if let Some(&(_, '{')) = chars.peek() {
                chars.next();
                // Found `{{`. Flush the literal buffer if non-empty,
                // then parse the expression up to the matching `}}`.
                if !literal_buf.is_empty() {
                    segments.push(Segment::Literal(std::mem::take(&mut literal_buf)));
                }
                let segment = parse_expression(&mut chars, template)?;
                segments.push(segment);
                continue;
            }
        }

        literal_buf.push(ch);
    }

    if !literal_buf.is_empty() {
        segments.push(Segment::Literal(literal_buf));
    }

    Ok(CompiledTemplate {
        raw: template.to_string(),
        segments,
    })
}

/// Parses the inside of a `{{ ... }}` pair plus the closing `}}`.
///
/// The opening `{{` has already been consumed. Returns the parsed
/// segment on success, or a `BadSyntax` error with a safe static
/// `field` label on failure.
fn parse_expression(
    chars: &mut std::iter::Peekable<std::str::CharIndices<'_>>,
    template: &str,
) -> Result<Segment, TemplateError> {
    let mut expr = String::new();
    let mut closed = false;

    while let Some((_, ch)) = chars.next() {
        if ch == '\\' {
            // No escapes are honoured inside expressions; surfacing as
            // BadSyntax with a safe label avoids any chance of leaking
            // a raw fragment back through the public error.
            return Err(TemplateError {
                raw_template: template.to_string(),
                field: "escape_inside_expr".to_string(),
                kind: TemplateErrorKind::BadSyntax,
            });
        }
        if ch == '}' {
            if let Some(&(_, '}')) = chars.peek() {
                chars.next();
                closed = true;
                break;
            }
        }
        expr.push(ch);
    }

    if !closed {
        return Err(TemplateError {
            raw_template: template.to_string(),
            field: "unclosed_braces".to_string(),
            kind: TemplateErrorKind::BadSyntax,
        });
    }

    let trimmed = expr.trim();
    if let Some(rest) = trimmed.strip_prefix("notification") {
        if rest.is_empty() {
            return Ok(Segment::NotificationPath(Vec::new()));
        }
        if let Some(path_str) = rest.strip_prefix('.') {
            let path: Vec<String> = path_str.split('.').map(str::to_string).collect();
            if path.iter().any(String::is_empty) {
                return Err(TemplateError {
                    raw_template: template.to_string(),
                    field: "empty_path_segment".to_string(),
                    kind: TemplateErrorKind::BadSyntax,
                });
            }
            return Ok(Segment::NotificationPath(path));
        }
        return Err(TemplateError {
            raw_template: template.to_string(),
            field: "unknown_namespace".to_string(),
            kind: TemplateErrorKind::BadSyntax,
        });
    }
    if let Some(rest) = trimmed.strip_prefix("env.") {
        if rest.is_empty() {
            return Err(TemplateError {
                raw_template: template.to_string(),
                field: "empty_path_segment".to_string(),
                kind: TemplateErrorKind::BadSyntax,
            });
        }
        return Ok(Segment::EnvVar(rest.to_string()));
    }
    Err(TemplateError {
        raw_template: template.to_string(),
        field: "unknown_namespace".to_string(),
        kind: TemplateErrorKind::BadSyntax,
    })
}

impl CompiledTemplate {
    /// Renders the template against `notification`, returning the
    /// substituted output string.
    ///
    /// Production callers use [`Self::render`]; tests inject a fake env
    /// resolver via [`Self::render_with_env`] to avoid mutating the
    /// process environment (`std::env::set_var` is `unsafe` and the
    /// crate forbids unsafe).
    pub(crate) fn render(&self, notification: &Notification) -> Result<String, TemplateError> {
        // Match VarError variants explicitly so a present-but-not-UTF-8
        // env var surfaces as the distinct `EnvNotUnicode` error rather
        // than collapsing into `EnvNotSet` (which would mislead the
        // operator looking for a misconfigured deployment when the
        // actual bug is in the value).
        self.render_with_env(notification, |name| match std::env::var(name) {
            Ok(value) => Ok(value),
            Err(std::env::VarError::NotPresent) => Err(TemplateErrorKind::EnvNotSet),
            Err(std::env::VarError::NotUnicode(_)) => Err(TemplateErrorKind::EnvNotUnicode),
        })
    }

    /// Renders the template with an injected env-var resolver.
    ///
    /// The resolver returns `Ok(value)` when the variable is set and
    /// usable, or `Err(kind)` when it is not; `kind` is the
    /// [`TemplateErrorKind`] that gets carried into the resulting
    /// `TemplateError`. Production wires `std::env::var` and maps
    /// `VarError::NotPresent` -> `EnvNotSet` and
    /// `VarError::NotUnicode` -> `EnvNotUnicode`. Tests pass a
    /// closure that returns hardcoded values.
    pub(crate) fn render_with_env<F>(
        &self,
        notification: &Notification,
        env_resolver: F,
    ) -> Result<String, TemplateError>
    where
        F: Fn(&str) -> Result<String, TemplateErrorKind>,
    {
        // Serialise the notification ONCE per render; cache the value
        // for the duration of this call so multiple notification-path
        // segments share the same JSON walk basis.
        //
        // A `serde_json::to_value` failure is reported with the
        // distinct `NotificationEncode` kind so the operator's
        // diagnosis points at the notification itself, not at a
        // missing template path. The well-typed `Notification`
        // shape makes this path practically unreachable, but the
        // mapping is correct if it ever fires.
        let notification_json: serde_json::Value =
            serde_json::to_value(notification).map_err(|_| TemplateError {
                raw_template: self.raw.clone(),
                field: "notification".to_string(),
                kind: TemplateErrorKind::NotificationEncode,
            })?;

        let mut out = String::new();
        for segment in &self.segments {
            match segment {
                Segment::Literal(s) => out.push_str(s),
                Segment::NotificationPath(path) => {
                    let value =
                        walk_path(&notification_json, path).ok_or_else(|| TemplateError {
                            raw_template: self.raw.clone(),
                            field: if path.is_empty() {
                                "notification".to_string()
                            } else {
                                format!("notification.{}", path.join("."))
                            },
                            kind: TemplateErrorKind::Missing,
                        })?;
                    out.push_str(&render_value(value));
                }
                Segment::EnvVar(name) => {
                    let value = env_resolver(name).map_err(|kind| TemplateError {
                        raw_template: self.raw.clone(),
                        field: name.clone(),
                        kind,
                    })?;
                    out.push_str(&value);
                }
            }
        }
        Ok(out)
    }

    /// Returns the original template source for tests that need to
    /// verify the engine retained the raw string for DEBUG-level
    /// tracing. Never include this in public error variants.
    #[cfg(test)]
    pub(crate) fn raw(&self) -> &str {
        &self.raw
    }
}

fn walk_path<'a>(root: &'a serde_json::Value, path: &[String]) -> Option<&'a serde_json::Value> {
    let mut cursor = root;
    for segment in path {
        cursor = cursor.get(segment)?;
    }
    Some(cursor)
}

/// Renders a single [`serde_json::Value`] into a substituted string per
/// the resolution rules in the module docs.
fn render_value(value: &serde_json::Value) -> String {
    match value {
        // Pattern-match the String variant to extract the inner str
        // without unwrap()/expect(); this preserves the "render scalar
        // strings UNQUOTED" rule while staying lint-clean.
        serde_json::Value::String(s) => s.clone(),
        serde_json::Value::Null => "null".to_string(),
        // Numbers, bools, objects, arrays: Display does the right
        // thing. Numbers and bools have no quotes; objects and arrays
        // serialise as compact JSON.
        other => other.to_string(),
    }
}

/// Converts the crate-private [`TemplateError`] to the public
/// [`crate::watch::TriggerError::Template`] variant.
///
/// The `context` is a SAFE static label set by the dispatch boundary
/// (`"command"`, `"webhook url"`, etc.), NOT the raw template text.
/// The raw template is emitted at DEBUG level for operators who
/// control the logging sink, but never reaches the public error.
pub(crate) fn template_error_to_trigger_error(
    e: TemplateError,
    context: impl Into<String>,
) -> crate::watch::TriggerError {
    let context_str = context.into();
    tracing::debug!(
        event.name = "client.trigger.template.render_failed",
        context = %context_str,
        raw_template = %e.raw_template,
        field = %e.field,
        kind = ?e.kind,
        "template render failed (raw template suppressed from public error)"
    );
    crate::watch::TriggerError::Template {
        context: context_str,
        field: e.field,
        kind: e.kind,
    }
}