Skip to main content

ordinary_config/app/template/
mod.rs

1// Copyright (C) 2026 Ordinary Labs, LLC.
2//
3// SPDX-License-Identifier: AGPL-3.0-only
4
5use crate::{Check, HttpCache, HttpCors, HttpCsp, StoredCache};
6use ordinary_types::Kind;
7use serde::{Deserialize, Serialize};
8
9/// Field used within the scope of a single template.
10#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
11#[cfg_attr(feature = "docs", derive(schemars::JsonSchema))]
12#[derive(Deserialize, Serialize, Debug, Clone)]
13pub struct TemplateField {
14    /// Field name
15    pub name: String,
16    /// Specifies the type of the value.
17    pub kind: Kind,
18    /// JSON value for template field.
19    pub value: serde_json::Value,
20}
21
22/// Query expression to be used in template bindings.
23#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
24#[cfg_attr(feature = "docs", derive(schemars::JsonSchema))]
25#[derive(Deserialize, Serialize, Debug, Clone)]
26pub enum QueryExpression {
27    Gte,
28    Gt,
29    Lte,
30    Lt,
31    Eq,
32    BeginsWith,
33}
34
35/// Binding options for template field refs.
36#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
37#[cfg_attr(feature = "docs", derive(schemars::JsonSchema))]
38#[derive(Deserialize, Serialize, Debug, Clone)]
39pub enum TemplateRefFieldBind {
40    /// Bind this property to a token claim field.
41    Token {
42        /// Name of the token claims field that this model/content
43        /// property should bind to.
44        ///
45        /// (i.e "account" if you'd like to have an "/account" route
46        /// that interprets the request based on the logged-in user's
47        /// token claims/fields).
48        field: String,
49        /// Include if this property is queryable.
50        #[serde(skip_serializing_if = "Option::is_none")]
51        #[serde(default)]
52        expression: Option<QueryExpression>,
53    },
54    /// Bind this property to a route segment (i.e /{something})
55    Segment {
56        /// Specifies the name of the route segment that this property is binding to.
57        name: String,
58        /// Include if this property is queryable.
59        #[serde(skip_serializing_if = "Option::is_none")]
60        #[serde(default)]
61        expression: Option<QueryExpression>,
62    },
63    /// Bind this property to a route segment (i.e /{something})
64    Param {
65        /// Specifies the key of the query string param that this property is binding to.
66        key: String,
67        /// Include if this property is queryable.
68        #[serde(skip_serializing_if = "Option::is_none")]
69        #[serde(default)]
70        expression: Option<QueryExpression>,
71    },
72}
73
74/// Declaration for which fields from a content definition
75/// or data model to include.
76#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
77#[cfg_attr(feature = "docs", derive(schemars::JsonSchema))]
78#[derive(Deserialize, Serialize, Debug, Clone, Default)]
79pub struct TemplateRefField {
80    /// Specifies index for reference field. Index
81    /// must be unique across fields for a given reference.
82    pub idx: u8,
83    /// Name of field to be included.
84    pub name: String,
85    /// Option to bind this field's value to a route
86    /// segment, querystring parameter or token field.
87    ///
88    /// **IMPORTANT:** this property is being deprecated.
89    /// Switch to using the `TemplateRef::predicate` config
90    /// field, instead.
91    #[serde(skip_serializing_if = "Option::is_none")]
92    #[serde(default)]
93    pub bind: Option<TemplateRefFieldBind>,
94    /// List of any nested subfields to include for this field.
95    #[cfg_attr(feature = "utoipa", schema(no_recursion))]
96    #[serde(skip_serializing_if = "Option::is_none")]
97    #[serde(default)]
98    pub fields: Option<Vec<TemplateRefField>>,
99}
100
101/// How templates reference Content Definitions
102/// and Data Models, and the specific fields it
103/// wants to include.
104#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
105#[cfg_attr(feature = "docs", derive(schemars::JsonSchema))]
106#[derive(Deserialize, Serialize, Debug, Clone, Default)]
107pub struct TemplateRef {
108    /// Specifies the index position for the referenced
109    /// data. Index must be unique across flags, params, data models and
110    /// content definitions.
111    pub idx: u8,
112    /// Name of the model or content definition.
113    pub name: String,
114    /// for requesting all top-level content items
115    ///
116    /// note: only supported for content. models use relationships.
117    #[serde(skip_serializing_if = "Option::is_none")]
118    #[serde(default)]
119    pub all: Option<String>,
120    /// Wires up the request parts (token claims, path segments, query string params)
121    /// to the content or model that is being referenced.
122    ///
123    /// ```jsonc
124    /// {
125    ///     // route segment
126    ///     "predicate": [["content_field_name", { "Segment": { "name": "route_segment" } }]]
127    /// }
128    /// ```
129    ///
130    /// Is ignored when `all` is set for content.
131    #[serde(skip_serializing_if = "Option::is_none")]
132    #[serde(default)]
133    pub predicate: Option<Vec<(String, TemplateRefFieldBind)>>,
134    /// Which fields to include.
135    ///
136    /// *Note:* If not specified will request all fields.
137    #[serde(skip_serializing_if = "Option::is_none")]
138    #[serde(default)]
139    pub fields: Option<Vec<TemplateRefField>>,
140}
141
142/// Server flags can only be used in templates whose cache is
143/// set to "Never".
144#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
145#[cfg_attr(feature = "docs", derive(schemars::JsonSchema))]
146#[derive(Deserialize, Serialize, Debug, Clone)]
147pub struct TemplateFlagRef {
148    /// Specifies the index position for the referenced
149    /// data. Index must be unique across flags, data models and
150    /// content definitions.
151    pub idx: u8,
152    /// Name of the flag.
153    pub name: String,
154}
155
156#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
157#[cfg_attr(feature = "docs", derive(schemars::JsonSchema))]
158#[derive(Deserialize, Serialize, Debug, Clone)]
159pub struct TemplateCache {
160    #[serde(skip_serializing_if = "Option::is_none")]
161    #[serde(default)]
162    pub stored: Option<StoredCache>,
163
164    #[serde(skip_serializing_if = "Option::is_none")]
165    #[serde(default)]
166    pub http: Option<HttpCache>,
167}
168
169#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
170#[cfg_attr(feature = "docs", derive(schemars::JsonSchema))]
171#[derive(Deserialize, Serialize, Debug, Clone, Default)]
172pub enum TemplateFfiVersion {
173    V1,
174    #[default]
175    V2,
176}
177
178/// Input serialization format. Output is always an array of bytes.
179#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
180#[cfg_attr(feature = "docs", derive(schemars::JsonSchema))]
181#[derive(Deserialize, Serialize, Debug, Clone, Default)]
182pub enum TemplateFfiSerialization {
183    #[default]
184    FlexBufferVector,
185    /// only supported for V2 and up
186    Json,
187}
188
189#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
190#[cfg_attr(feature = "docs", derive(schemars::JsonSchema))]
191#[derive(Deserialize, Serialize, Debug, Clone, Default)]
192pub struct TemplateFfi {
193    pub version: TemplateFfiVersion,
194    pub serialization: TemplateFfiSerialization,
195}
196
197#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
198#[cfg_attr(feature = "docs", derive(schemars::JsonSchema))]
199#[derive(Deserialize, Serialize, Debug, Clone, Default)]
200pub enum BindgenLang {
201    #[default]
202    #[serde(rename = "rust")]
203    Rust,
204}
205
206#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
207#[cfg_attr(feature = "docs", derive(schemars::JsonSchema))]
208#[derive(Deserialize, Serialize, Debug, Clone, Default)]
209pub enum ManagedTemplate {
210    /// Compiles a Rust crate as a `WASIp2` component, containing an
211    /// [Askama](https://askama.rs/en/stable/) template.
212    #[default]
213    #[serde(rename = "v2-rust-askama")]
214    V2RustAskama,
215}
216
217impl ManagedTemplate {
218    #[must_use]
219    pub fn as_str(&self) -> &'static str {
220        match self {
221            Self::V2RustAskama => "v2-rust-askama",
222        }
223    }
224}
225
226#[cfg(feature = "cli")]
227impl clap::ValueEnum for ManagedTemplate {
228    fn value_variants<'a>() -> &'a [Self] {
229        &[Self::V2RustAskama]
230    }
231
232    fn to_possible_value(&self) -> Option<clap::builder::PossibleValue> {
233        match self {
234            Self::V2RustAskama => Some(clap::builder::PossibleValue::new("v2-rust-askama")),
235        }
236    }
237}
238
239/// Template configuration for Ordinary Applications.
240#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
241#[cfg_attr(feature = "docs", derive(schemars::JsonSchema))]
242#[derive(Deserialize, Serialize, Debug, Clone, Default)]
243pub struct TemplateConfig {
244    /// Foreign function interface config
245    #[serde(skip_serializing_if = "Option::is_none")]
246    #[serde(default)]
247    pub ffi: Option<TemplateFfi>,
248    /// Refers to the template config in another location
249    #[serde(skip_serializing_if = "Option::is_none")]
250    #[serde(default, rename = "ref")]
251    pub r#ref: Option<String>,
252    /// Unique index for template.
253    #[serde(skip_serializing_if = "Option::is_none")]
254    #[serde(default)]
255    pub idx: Option<u8>,
256    /// Template's name.
257    #[serde(skip_serializing_if = "Option::is_none")]
258    #[serde(default)]
259    pub name: Option<String>,
260    /// Language + Engine for basic templating
261    ///
262    /// If unset, template is in "ejected" state and
263    /// self-managed by user.
264    #[serde(skip_serializing_if = "Option::is_none")]
265    #[serde(default)]
266    pub managed: Option<ManagedTemplate>,
267    /// Used as the `content-type` header for HTTP responses.
268    /// Validated against file extension (if `path` is present).
269    // todo: switch this to an enum of mime types
270    #[serde(skip_serializing_if = "Option::is_none")]
271    #[serde(default)]
272    pub mime: Option<String>,
273    /// Specifies whether the content in the file should
274    /// be "minified"/have whitespace removed.
275    #[serde(skip_serializing_if = "Option::is_none")]
276    #[serde(default)]
277    pub minify: Option<bool>,
278
279    /// build scripts for ensuring the WebAssembly components
280    /// are composed properly at build time.
281    #[serde(skip_serializing_if = "Option::is_none")]
282    #[serde(default)]
283    pub build: Option<Vec<Vec<String>>>,
284
285    /// path to the WASM component binary
286    #[serde(skip_serializing_if = "Option::is_none")]
287    #[serde(default)]
288    pub bin: Option<String>,
289
290    /// which language the input binding WebAssembly
291    /// Component should be compiled _from_.
292    ///
293    /// Note: because the bindings are compiled _to_
294    /// a WebAssembly Component, with a companion WIT
295    /// file, the resulting bindings can be used by any
296    /// language that has WIT support.
297    ///
298    /// i.e. bindings compiled from Rust can be plugged
299    /// into a Golang WebAssembly component.
300    ///
301    /// **Important**: if your FFI serialization format
302    /// is JSON, or you are comfortable writing your own
303    /// `FlexBuffer` vector accessors it is not necessary
304    /// to generate the bindings (other than for reference
305    /// in the `FlexBuffer` vector accessing case).
306    #[serde(skip_serializing_if = "Option::is_none")]
307    #[serde(default)]
308    pub bindgen: Option<BindgenLang>,
309
310    /// Relative path to the template file
311    #[serde(skip_serializing_if = "Option::is_none")]
312    #[serde(default)]
313    pub path: Option<String>,
314    /// The route used in the HTTP server to serve this template.
315    /// Can use segments to bind to properties on models or content
316    #[serde(skip_serializing_if = "Option::is_none")]
317    #[serde(default)]
318    pub route: Option<String>,
319    /// What to check the token fields against. If left blank, route is considered public.
320    #[serde(skip_serializing_if = "Option::is_none")]
321    #[serde(default)]
322    pub protected: Option<Check>,
323    /// Used to specify the cache policy for this template.
324    #[serde(skip_serializing_if = "Option::is_none")]
325    #[serde(default)]
326    pub cache: Option<TemplateCache>,
327
328    /// HTTP Content Security Policy configuration.
329    ///
330    /// <https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP>
331    ///
332    /// "Base" defaults to `default-src 'self';` and tacks on SHA-256 integrity
333    /// hashes for all inlined scripts and styles (generated at build time) to
334    /// `script-src 'self' sha256-b64` and `style-src 'self' sha256-b64`, respectively.
335    ///
336    /// `https:` is used when not running in `--insecure` mode.
337    #[serde(skip_serializing_if = "Option::is_none")]
338    #[serde(default)]
339    pub csp: Option<HttpCsp>,
340
341    #[serde(skip_serializing_if = "Option::is_none")]
342    #[serde(default)]
343    pub cors: Option<HttpCors>,
344
345    /// Max duration for the template.
346    ///
347    /// Unit: seconds
348    #[serde(skip_serializing_if = "Option::is_none")]
349    #[serde(default)]
350    pub timeout: Option<u16>,
351
352    /// Used for template-specific variables that don't need to be shared
353    /// beyond the scope of the given template, and don't warrant a content
354    /// object.
355    #[serde(skip_serializing_if = "Option::is_none")]
356    #[serde(default)]
357    pub fields: Option<Vec<TemplateField>>,
358    /// List of global variables to be included with the compiled template
359    /// binary. Globals are excluded by default and have to be explicitly
360    /// listed in the globals to be accessed from the template.
361    #[serde(skip_serializing_if = "Option::is_none")]
362    #[serde(default)]
363    pub globals: Option<Vec<String>>,
364    /// List of flags to be referenced by the template.
365    #[serde(skip_serializing_if = "Option::is_none")]
366    #[serde(default)]
367    pub flags: Option<Vec<TemplateFlagRef>>,
368    /// List of models and what fields the template needs from the models.
369    /// This is effectively a query definition.
370    #[serde(skip_serializing_if = "Option::is_none")]
371    #[serde(default)]
372    pub models: Option<Vec<TemplateRef>>,
373    /// List of content definitions and the content definition fields
374    /// that this template will use.
375    #[serde(skip_serializing_if = "Option::is_none")]
376    #[serde(default)]
377    pub content: Option<Vec<TemplateRef>>,
378
379    /// Specifies which actions this template is intended to trigger.
380    ///
381    /// Has no effect at runtime, is only useful for tooling.
382    #[serde(skip_serializing_if = "Option::is_none")]
383    #[serde(default)]
384    pub actions: Option<Vec<String>>,
385
386    /// List of build time environment variables to be
387    /// replaced directly in the template string.
388    ///
389    /// format in template: `{{ YOUR_VAR }}`
390    #[serde(skip_serializing_if = "Option::is_none")]
391    #[serde(default)]
392    pub variables: Option<Vec<String>>,
393
394    /// list of names of middleware to include for this template
395    #[serde(skip_serializing_if = "Option::is_none")]
396    #[serde(default)]
397    pub middlewares: Option<Vec<String>>,
398}
399
400impl TemplateConfig {
401    pub fn load_managed(&mut self) {
402        let template_name = self.name_validated().to_owned();
403
404        if let Some(managed) = &self.managed {
405            match managed {
406                ManagedTemplate::V2RustAskama => {
407                    self.ffi = Some(TemplateFfi {
408                        version: TemplateFfiVersion::V2,
409                        serialization: TemplateFfiSerialization::FlexBufferVector,
410                    });
411
412                    self.bindgen = Some(BindgenLang::Rust);
413                    self.build = Some(vec![vec![
414                        "sh".into(),
415                        format!(".ordinary/{template_name}/build.sh"),
416                    ]]);
417                    self.bin = Some(format!(".ordinary/{template_name}/component.wasm"));
418                }
419            }
420        }
421    }
422
423    /// ## Panics
424    ///
425    /// This method panics if the `idx` field has not been validated to be present
426    #[must_use]
427    pub fn idx_validated(&self) -> u8 {
428        self.idx.expect("idx should not be 'None' after validation")
429    }
430
431    /// ## Panics
432    ///
433    /// This method panics if the `name` field has not been validated to be present
434    #[must_use]
435    pub fn name_validated(&self) -> &str {
436        self.name
437            .as_ref()
438            .expect("name should not be 'None' after validation")
439    }
440
441    /// ## Panics
442    ///
443    /// This method panics if the `name` field has not been validated to be present
444    #[must_use]
445    pub fn route_validated(&self) -> &str {
446        self.route
447            .as_ref()
448            .expect("route should not be 'None' after validation")
449    }
450
451    /// ## Panics
452    ///
453    /// This method panics if the `name` field has not been validated to be present
454    #[must_use]
455    pub fn mime_validated(&self) -> &str {
456        self.mime
457            .as_ref()
458            .expect("route should not be 'None' after validation")
459    }
460
461    /// ## Panics
462    ///
463    /// This method panics if the `ffi` field has not been validated to be present
464    #[must_use]
465    pub fn ffi_validated(&self) -> &TemplateFfi {
466        self.ffi
467            .as_ref()
468            .expect("ffi should not be 'None' after validation")
469    }
470}