Skip to main content

ordinary_modify/
lib.rs

1#![doc = include_str!("../README.md")]
2#![warn(clippy::all, clippy::pedantic)]
3#![allow(clippy::missing_errors_doc)]
4
5// Copyright (C) 2026 Ordinary Labs, LLC.
6//
7// SPDX-License-Identifier: AGPL-3.0-only
8
9pub mod content;
10pub mod project;
11
12use anyhow::bail;
13use heck::ToTitleCase;
14use ordinary_config::{
15    ActionConfig, ActionFfi, ActionFfiSerialization, ActionFfiVersion, ErrorConfig,
16    IntegrationConfig, IntegrationProtocol, ModelConfig, OrdinaryConfig, TemplateConfig,
17    TemplateFfi, TemplateFfiSerialization, TemplateFfiVersion, TemplateRef, UuidVersion,
18};
19use std::{io::Write, path::Path};
20use tracing::instrument;
21
22#[instrument(err)]
23#[allow(
24    clippy::too_many_arguments,
25    clippy::used_underscore_binding,
26    clippy::too_many_lines
27)]
28pub fn add_action(
29    path: &str,
30    name: &str,
31    lang: &str,
32    _access: (),
33    _accepts: (),
34    _returns: (),
35    transactional: &Option<bool>,
36    _protected: (),
37) -> anyhow::Result<()> {
38    let proj_path = Path::new(path);
39    let mut app_config = OrdinaryConfig::get(path)?;
40
41    let mut actions = app_config.actions.unwrap_or_default();
42
43    if actions.len() <= 225 {
44        let next_idx = actions.len();
45
46        let language = match lang {
47            "Rust" | "rust" | "rs" => ordinary_config::ActionLang::Rust,
48            "JavaScript" | "javascript" | "JS" | "js" => ordinary_config::ActionLang::JavaScript,
49            _ => bail!("language {lang} not yet supported"),
50        };
51
52        let action_dir_path = proj_path.join("actions").join(name);
53        fs_err::create_dir_all(&action_dir_path)?;
54
55        match language {
56            ordinary_config::ActionLang::Rust => {
57                let gitignore = "/target";
58
59                let cargo_toml = format!(
60                    r#"[workspace]
61
62[package]
63name = "action"
64version = "0.1.0"
65edition = "2024"
66
67[dependencies]
68ordinary = {{ path = "../../.ordinary/gen/actions/{name}" }}
69
70[profile.release]
71strip = "symbols"
72lto = "fat"
73opt-level = "z"
74codegen-units = 1
75panic = "abort"
76"#
77                );
78
79                let main_rs = r#"use std::error::Error;
80use ordinary::{recv_in, send_out};
81
82fn main() -> Result<(), Box<dyn Error>> {
83    let _input = recv_in()?;
84
85    ordinary::trace(2, "Hello, Ordinary!")?;
86
87    send_out(())
88}
89"#;
90
91                let mut gitignore_file = fs_err::File::create(action_dir_path.join(".gitignore"))?;
92                gitignore_file.write_all(gitignore.as_bytes())?;
93
94                let mut cargo_toml_file = fs_err::File::create(action_dir_path.join("Cargo.toml"))?;
95                cargo_toml_file.write_all(cargo_toml.as_bytes())?;
96
97                fs_err::create_dir_all(action_dir_path.join("src"))?;
98                let mut main_rs_file =
99                    fs_err::File::create(action_dir_path.join("src").join("main.rs"))?;
100                main_rs_file.write_all(main_rs.as_bytes())?;
101            }
102            ordinary_config::ActionLang::JavaScript => {
103                let gitignore = r"/target
104node_modules";
105
106                let package_json = r#"{
107  "name": "action",
108  "version": "0.1.0",
109  "main": "index.js",
110  "files": [
111    "index.js",
112    "index.d.ts"
113  ],
114  "types": "client.d.ts",
115  "scripts": {
116    "build": "esbuild main.js --bundle --minify --outfile=index.js --target=es2020 --format=iife --global-name=action"
117  },
118  "devDependencies": {
119    "esbuild": "0.25.10"
120  }
121}
122                "#;
123
124                let main_js = r#"export function main(input) {
125    console.log("Hello, Ordinary!");
126}"#;
127
128                let mut gitignore_file = fs_err::File::create(action_dir_path.join(".gitignore"))?;
129                gitignore_file.write_all(gitignore.as_bytes())?;
130
131                let mut package_json_file =
132                    fs_err::File::create(action_dir_path.join("package.json"))?;
133                package_json_file.write_all(package_json.as_bytes())?;
134
135                let mut main_js_file = fs_err::File::create(action_dir_path.join("main.js"))?;
136                main_js_file.write_all(main_js.as_bytes())?;
137            }
138        }
139
140        actions.push(ActionConfig {
141            ffi: ActionFfi {
142                version: ActionFfiVersion::V1,
143                serialization: ActionFfiSerialization::FlexBufferVector,
144            },
145            idx: u8::try_from(next_idx)?,
146            dir_path: Some(format!("./actions/{name}")),
147            name: name.to_string(),
148            lang: language,
149            access: vec![],
150            accepts: ordinary_types::Kind::Void,
151            returns: ordinary_types::Kind::Void,
152            triggered_by: vec![],
153            transactional: *transactional,
154            protected: None,
155            wasm_opt: None,
156            timeout: None,
157            cors: None,
158            privileged: None,
159            variables: None,
160            middlewares: None,
161        });
162
163        app_config.actions = Some(actions);
164
165        let ordinary_json = serde_json::to_string_pretty(&app_config)?;
166
167        let mut file = fs_err::File::create(proj_path.join("ordinary.json"))?;
168        file.write_all(ordinary_json.as_bytes())?;
169    } else {
170        tracing::error!("cannot support more than 255 actions for a single project.");
171    }
172
173    Ok(())
174}
175
176#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
177#[instrument(skip_all, err)]
178pub fn add_template(
179    path: &str,
180    name: &str,
181    route: &str,
182    mime: &str,
183    head_block: &str,
184    header_block: &str,
185    footer_block: &str,
186    is_error: bool,
187    globals: Option<Vec<String>>,
188    content_refs: Option<Vec<TemplateRef>>,
189) -> anyhow::Result<()> {
190    let proj_path = Path::new(path);
191    let mut app_config = OrdinaryConfig::get(path)?;
192
193    let mut templates = app_config.templates.unwrap_or_default();
194
195    if templates.len() <= 255 {
196        let next_idx = templates.len();
197
198        let cache = None;
199
200        let file_ext = match mime {
201            "text/html" | "text/html; charset=utf-8" => "html",
202            "text/xml" | "application/rss+xml" => "xml",
203            "text/plain" | "text/plain; charset=utf-8" => "txt",
204            _ => "",
205        };
206
207        fs_err::create_dir_all(proj_path.join("templates"))?;
208
209        let file_name = format!("{name}.{file_ext}");
210        let mut file = fs_err::File::create(proj_path.join("templates").join(&file_name))?;
211
212        let header_block = if header_block.is_empty() {
213            r#"<header>
214                <a href="/">{{ canonical }}</a>
215            </header>"#
216        } else {
217            header_block
218        };
219
220        let footer_block = if footer_block.is_empty() {
221            r#"<footer>
222                © 2026 Your Name | powered by&nbsp;<a href="https://codeberg.org/ordinarylabs/Ordinary">Ordinary</a>
223            </footer>"#
224        } else {
225            footer_block
226        };
227
228        // todo: have a default file for each extension/mime type
229        if mime == "text/html" {
230            let main_block = if is_error {
231                "<h1>Status Code {{ error.code }}</h1>
232                <p>{{ error.message }}</p>"
233                    .to_string()
234            } else {
235                format!("<h1>{}</h1>", name.to_title_case())
236            };
237
238            let default_html = format!(
239                r#"<!DOCTYPE html>
240        <html lang="en">
241
242        <head>
243            <meta charset="UTF-8">
244            <meta name="viewport" content="width=device-width, initial-scale=1.0">
245
246            <title>{name} | {{{{ canonical }}}}</title>
247            <meta name="generator" content="{{{{ generator }}}}">
248        {head_block}
249        </head>
250
251        <body>
252            {header_block}
253            <main>
254                {main_block}
255            </main>
256            {footer_block}
257        </body>
258        "#,
259            );
260            file.write_all(default_html.as_bytes())?;
261            file.flush()?;
262        } else {
263            file.write_all(&[][..])?;
264            file.flush()?;
265        }
266
267        templates.push(TemplateConfig {
268            ffi: TemplateFfi {
269                version: TemplateFfiVersion::V1,
270                serialization: TemplateFfiSerialization::FlexBufferVector,
271            },
272            idx: u8::try_from(next_idx)?,
273            name: name.to_string(),
274            // todo: verify that route begins with "/" and is valid HTTP route
275            route: route.to_string(),
276            // todo: check against valid mime types
277            mime: mime.to_string(),
278            cache,
279            path: Some(format!("./templates/{file_name}")),
280            globals,
281            content: content_refs,
282            ..Default::default()
283        });
284
285        app_config.templates = Some(templates);
286        if is_error {
287            app_config.error = Some(ErrorConfig {
288                template: Some(name.to_string()),
289                asset: None,
290            });
291        }
292
293        let ordinary_json = serde_json::to_string_pretty(&app_config)?;
294
295        let mut file = fs_err::File::create(proj_path.join("ordinary.json"))?;
296        file.write_all(ordinary_json.as_bytes())?;
297    } else {
298        tracing::error!("cannot support more than 255 templates for a single project.");
299    }
300
301    Ok(())
302}
303
304#[instrument(err)]
305pub fn add_integration(
306    path: &str,
307    name: &str,
308    endpoint: &str,
309    protocol: &str,
310) -> anyhow::Result<()> {
311    let proj_path = Path::new(path);
312    let mut app_config = OrdinaryConfig::get(path)?;
313
314    let mut integrations = app_config.integrations.unwrap_or_default();
315
316    if integrations.len() <= 255 {
317        let next_idx = integrations.len();
318
319        integrations.push(IntegrationConfig {
320            idx: u8::try_from(next_idx)?,
321            name: name.to_string(),
322            protocol: match protocol {
323                "JSON" => IntegrationProtocol::Http {
324                    method: "GET".to_string(),
325                    headers: vec![],
326                    send_encoding: ordinary_config::IntegrationProtocolHttpEncoding::Text,
327                    recv_encoding: ordinary_config::IntegrationProtocolHttpEncoding::Text,
328                },
329                _ => bail!("invalid protocol"),
330            },
331            endpoint: endpoint.to_string(),
332            send: ordinary_types::Kind::Json,
333            recv: ordinary_types::Kind::Json,
334            secrets: None,
335            timeout: None,
336        });
337
338        app_config.integrations = Some(integrations);
339
340        let ordinary_json = serde_json::to_string_pretty(&app_config)?;
341
342        let mut file = fs_err::File::create(proj_path.join("ordinary.json"))?;
343        file.write_all(ordinary_json.as_bytes())?;
344    } else {
345        tracing::error!("cannot support more than 255 integrations for a single project.");
346    }
347
348    Ok(())
349}
350
351#[instrument(err)]
352pub fn add_model(path: &str, name: &str, uuid: Option<&str>) -> anyhow::Result<()> {
353    let proj_path = Path::new(path);
354    let mut app_config = OrdinaryConfig::get(path)?;
355
356    let mut models = app_config.models.unwrap_or_default();
357
358    if models.len() <= 255 {
359        let next_idx = models.len();
360
361        models.push(ModelConfig {
362            idx: u8::try_from(next_idx)?,
363            name: name.to_string(),
364            fields: vec![],
365            uuid: match uuid {
366                Some(uuid) => match uuid {
367                    "v4" => Some(UuidVersion::V4),
368                    "v7" => Some(UuidVersion::V7),
369                    _ => bail!("invalid UUID version"),
370                },
371                None => None,
372            },
373        });
374
375        app_config.models = Some(models);
376
377        let ordinary_json = serde_json::to_string_pretty(&app_config)?;
378
379        let mut file = fs_err::File::create(proj_path.join("ordinary.json"))?;
380        file.write_all(ordinary_json.as_bytes())?;
381    } else {
382        tracing::error!("cannot support more than 255 models for a single project.");
383    }
384
385    Ok(())
386}