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
9use anyhow::bail;
10use heck::ToTitleCase;
11use ordinary_config::{
12    ActionConfig, ActionFfi, ActionFfiSerialization, ActionFfiVersion, Content, ContentDefinition,
13    IntegrationConfig, IntegrationProtocol, ModelConfig, OrdinaryConfig, TemplateCache,
14    TemplateConfig, TemplateFfi, TemplateFfiSerialization, TemplateFfiVersion, UuidVersion,
15};
16use ordinary_types::ContentObject;
17use std::{io::Write, path::Path};
18use tracing::instrument;
19
20#[instrument(err)]
21pub fn create_project(path: &String, domain: &String) -> anyhow::Result<()> {
22    let path = Path::new(path).join(domain);
23
24    let gitignore = r".ordinary
25.env
26/target
27";
28
29    tracing::info!("creating project at {:?}", path);
30    fs_err::create_dir_all(&path)?;
31
32    let app_config = OrdinaryConfig {
33        domain: domain.clone(),
34        cnames: None,
35
36        version: "0.1.0".into(),
37        contacts: vec![],
38        hide_contacts: None,
39
40        storage_size: 10_000_000,
41
42        default_timeout: None,
43        csp: None,
44        cors: None,
45
46        runtime: None,
47
48        hide_schema: None,
49
50        obfuscation: None,
51        client_rendering: None,
52        client_events: None,
53
54        port: None,
55        redirect_port: None,
56        logging: None,
57        globals: None,
58        secrets: None,
59
60        // todo: all of these should be set with defaults
61        assets: None,
62        content: None,
63        error: None,
64
65        flags: None,
66
67        templates: None,
68        auth: None,
69        actions: None,
70        models: None,
71        integrations: None,
72    };
73
74    let ordinary_json = serde_json::to_string_pretty(&app_config)?;
75
76    let mut file = fs_err::File::create(path.join("ordinary.json"))?;
77    file.write_all(ordinary_json.as_bytes())?;
78
79    let mut file = fs_err::File::create(path.join(".gitignore"))?;
80    file.write_all(gitignore.as_bytes())?;
81
82    if let Some(path) = path.to_str() {
83        add_template(path, "index", "/", "text/html")?;
84    }
85
86    Ok(())
87}
88
89#[instrument(err)]
90#[allow(
91    clippy::too_many_arguments,
92    clippy::used_underscore_binding,
93    clippy::too_many_lines
94)]
95pub fn add_action(
96    path: &str,
97    name: &str,
98    lang: &str,
99    _access: (),
100    _accepts: (),
101    _returns: (),
102    transactional: &Option<bool>,
103    _protected: (),
104) -> anyhow::Result<()> {
105    let proj_path = Path::new(path);
106    let ordinary_json = fs_err::read_to_string(proj_path.join("ordinary.json"))?;
107
108    let mut app_config: OrdinaryConfig = serde_json::from_str(&ordinary_json)?;
109
110    let mut actions = app_config.actions.unwrap_or_default();
111
112    if actions.len() <= 225 {
113        let next_idx = actions.len();
114
115        let language = match lang {
116            "Rust" | "rust" | "rs" => ordinary_config::ActionLang::Rust,
117            "JavaScript" | "javascript" | "JS" | "js" => ordinary_config::ActionLang::JavaScript,
118            _ => bail!("language {lang} not yet supported"),
119        };
120
121        let action_dir_path = proj_path.join("actions").join(name);
122        fs_err::create_dir_all(&action_dir_path)?;
123
124        match language {
125            ordinary_config::ActionLang::Rust => {
126                let gitignore = "/target";
127
128                let cargo_toml = format!(
129                    r#"[workspace]
130
131[package]
132name = "action"
133version = "0.1.0"
134edition = "2024"
135
136[dependencies]
137ordinary = {{ path = "../../.ordinary/gen/actions/{name}" }}
138
139[profile.release]
140strip = "symbols"
141lto = "fat"
142opt-level = "z"
143codegen-units = 1
144panic = "abort"
145"#
146                );
147
148                let main_rs = r#"use std::error::Error;
149use ordinary::{recv_in, send_out};
150
151fn main() -> Result<(), Box<dyn Error>> {
152    let _input = recv_in()?;
153    
154    ordinary::trace(2, "Hello, Ordinary!")?;
155
156    send_out(())
157}
158"#;
159
160                let mut gitignore_file = fs_err::File::create(action_dir_path.join(".gitignore"))?;
161                gitignore_file.write_all(gitignore.as_bytes())?;
162
163                let mut cargo_toml_file = fs_err::File::create(action_dir_path.join("Cargo.toml"))?;
164                cargo_toml_file.write_all(cargo_toml.as_bytes())?;
165
166                fs_err::create_dir_all(action_dir_path.join("src"))?;
167                let mut main_rs_file =
168                    fs_err::File::create(action_dir_path.join("src").join("main.rs"))?;
169                main_rs_file.write_all(main_rs.as_bytes())?;
170            }
171            ordinary_config::ActionLang::JavaScript => {
172                let gitignore = r"/target
173node_modules";
174
175                let package_json = r#"{
176  "name": "action",
177  "version": "0.1.0",
178  "main": "index.js",
179  "files": [
180    "index.js",
181    "index.d.ts"
182  ],
183  "types": "client.d.ts",
184  "scripts": {
185    "build": "esbuild main.js --bundle --minify --outfile=index.js --target=es2020 --format=iife --global-name=action"
186  },
187  "devDependencies": {
188    "esbuild": "0.25.10"
189  }
190}
191                "#;
192
193                let main_js = r#"export function main(input) {
194    console.log("Hello, Ordinary!");                
195}"#;
196
197                let mut gitignore_file = fs_err::File::create(action_dir_path.join(".gitignore"))?;
198                gitignore_file.write_all(gitignore.as_bytes())?;
199
200                let mut package_json_file =
201                    fs_err::File::create(action_dir_path.join("package.json"))?;
202                package_json_file.write_all(package_json.as_bytes())?;
203
204                let mut main_js_file = fs_err::File::create(action_dir_path.join("main.js"))?;
205                main_js_file.write_all(main_js.as_bytes())?;
206            }
207        }
208
209        actions.push(ActionConfig {
210            ffi: ActionFfi {
211                version: ActionFfiVersion::V1,
212                serialization: ActionFfiSerialization::FlexBufferVector,
213            },
214            idx: u8::try_from(next_idx)?,
215            dir_path: format!("./actions/{name}"),
216            name: name.to_string(),
217            lang: language,
218            access: vec![],
219            accepts: ordinary_types::Kind::Void,
220            returns: ordinary_types::Kind::Void,
221            triggered_by: vec![],
222            transactional: *transactional,
223            protected: None,
224            wasm_opt: None,
225            timeout: None,
226            cors: None,
227            privileged: None,
228            variables: None,
229        });
230
231        app_config.actions = Some(actions);
232
233        let ordinary_json = serde_json::to_string_pretty(&app_config)?;
234
235        let mut file = fs_err::File::create(proj_path.join("ordinary.json"))?;
236        file.write_all(ordinary_json.as_bytes())?;
237    } else {
238        tracing::error!("cannot support more than 255 actions for a single project.");
239    }
240
241    Ok(())
242}
243
244#[instrument(err)]
245pub fn add_template(path: &str, name: &str, route: &str, mime: &str) -> anyhow::Result<()> {
246    let proj_path = Path::new(path);
247    let ordinary_json = fs_err::read_to_string(proj_path.join("ordinary.json"))?;
248
249    let mut app_config: OrdinaryConfig = serde_json::from_str(&ordinary_json)?;
250
251    let mut templates = app_config.templates.unwrap_or_default();
252
253    if templates.len() <= 255 {
254        let next_idx = templates.len();
255
256        let cache = Some(TemplateCache {
257            stored: None,
258            http: None,
259        });
260
261        let file_ext = match mime {
262            "text/html" => "html",
263            "text/xml" => "xml",
264            "text/plain" => "txt",
265            _ => "",
266        };
267
268        fs_err::create_dir_all(proj_path.join("templates"))?;
269
270        let file_name = format!("{name}.{file_ext}");
271
272        let mut file = fs_err::File::create(proj_path.join("templates").join(&file_name))?;
273        // todo: have a default file for each extension/mime type
274
275        match mime {
276            "text/html" => {
277                let default_html = format!(
278                    r#"<!DOCTYPE html>
279<html lang="en">
280
281<head>
282    <meta charset="UTF-8">
283    <meta name="viewport" content="width=device-width, initial-scale=1.0">
284
285    <title>{} | {{{{ domain }}}}</title>
286</head>
287
288<body>
289    <header>
290        <a href="/">{{{{ domain }}}}</a>
291    </header>
292    <main>
293        <h1>{}</h1>
294    </main>
295    <hr>
296    <footer>
297        © 2026 Your Name | powered by&nbsp;<a href="https://codeberg.org/ordinarylabs/Ordinary">Ordinary</a>
298    </footer>
299</body>
300"#,
301                    name,
302                    name.to_title_case()
303                );
304                file.write_all(default_html.as_bytes())?;
305            }
306            _ => {
307                file.write_all(&[][..])?;
308            }
309        }
310
311        templates.push(TemplateConfig {
312            ffi: TemplateFfi {
313                version: TemplateFfiVersion::V1,
314                serialization: TemplateFfiSerialization::FlexBufferVector,
315            },
316            idx: u8::try_from(next_idx)?,
317            name: name.to_string(),
318            // todo: verify that route begins with "/" and is valid HTTP route
319            route: route.to_string(),
320            // todo: check against valid mime types
321            mime: mime.to_string(),
322            cache,
323            csp: None,
324            cors: None,
325            path: Some(format!("./templates/{file_name}")),
326            protected: None,
327            globals: None,
328            flags: None,
329            fields: None,
330            params: None,
331            content: None,
332            models: None,
333            actions: None,
334            minify: None,
335            wasm_opt: None,
336            timeout: None,
337            variables: None,
338        });
339
340        app_config.templates = Some(templates);
341
342        let ordinary_json = serde_json::to_string_pretty(&app_config)?;
343
344        let mut file = fs_err::File::create(proj_path.join("ordinary.json"))?;
345        file.write_all(ordinary_json.as_bytes())?;
346    } else {
347        tracing::error!("cannot support more than 255 templates for a single project.");
348    }
349
350    Ok(())
351}
352
353#[instrument(err)]
354pub fn add_integration(
355    path: &str,
356    name: &str,
357    endpoint: &str,
358    protocol: &str,
359) -> anyhow::Result<()> {
360    let proj_path = Path::new(path);
361    let ordinary_json = fs_err::read_to_string(proj_path.join("ordinary.json"))?;
362
363    let mut app_config: OrdinaryConfig = serde_json::from_str(&ordinary_json)?;
364
365    let mut integrations = app_config.integrations.unwrap_or_default();
366
367    if integrations.len() <= 255 {
368        let next_idx = integrations.len();
369
370        integrations.push(IntegrationConfig {
371            idx: u8::try_from(next_idx)?,
372            name: name.to_string(),
373            protocol: match protocol {
374                "JSON" => IntegrationProtocol::Http {
375                    method: "GET".to_string(),
376                    headers: vec![],
377                    send_encoding: ordinary_config::IntegrationProtocolHttpEncoding::Text,
378                    recv_encoding: ordinary_config::IntegrationProtocolHttpEncoding::Text,
379                },
380                _ => bail!("invalid protocol"),
381            },
382            endpoint: endpoint.to_string(),
383            send: ordinary_types::Kind::Json,
384            recv: ordinary_types::Kind::Json,
385            secrets: None,
386            timeout: None,
387        });
388
389        app_config.integrations = Some(integrations);
390
391        let ordinary_json = serde_json::to_string_pretty(&app_config)?;
392
393        let mut file = fs_err::File::create(proj_path.join("ordinary.json"))?;
394        file.write_all(ordinary_json.as_bytes())?;
395    } else {
396        tracing::error!("cannot support more than 255 integrations for a single project.");
397    }
398
399    Ok(())
400}
401
402#[instrument(err)]
403pub fn add_content_def(path: &str, name: &String) -> anyhow::Result<()> {
404    let proj_path = Path::new(path);
405    let ordinary_json = fs_err::read_to_string(proj_path.join("ordinary.json"))?;
406
407    let mut app_config: OrdinaryConfig = serde_json::from_str(&ordinary_json)?;
408
409    let mut file_path = "./content.json".to_string();
410
411    let mut content_defs = if let Some(d) = app_config.content {
412        file_path = d.file_path;
413        d.definitions
414    } else {
415        let mut file = fs_err::File::create(proj_path.join("content.json"))?;
416        file.write_all(b"[]")?;
417
418        vec![]
419    };
420
421    if content_defs.len() < 255 {
422        let next_idx = content_defs.len();
423
424        content_defs.push(ContentDefinition {
425            idx: u8::try_from(next_idx)?,
426            name: name.clone(),
427            fields: vec![],
428        });
429
430        app_config.content = Some(Content {
431            definitions: content_defs,
432            file_path,
433        });
434
435        let ordinary_json = serde_json::to_string_pretty(&app_config)?;
436
437        let mut file = fs_err::File::create(proj_path.join("ordinary.json"))?;
438        file.write_all(ordinary_json.as_bytes())?;
439    } else {
440        tracing::error!("cannot support more than 255 content definitions for a single project.");
441    }
442
443    Ok(())
444}
445
446#[instrument(err)]
447pub fn add_content_obj(path: &str, object_json: &String) -> anyhow::Result<()> {
448    let proj_path = Path::new(path);
449    let ordinary_json = fs_err::read_to_string(proj_path.join("ordinary.json"))?;
450
451    let app_config: OrdinaryConfig = serde_json::from_str(&ordinary_json)?;
452
453    if let Some(content) = app_config.content {
454        let content_json = fs_err::read_to_string(proj_path.join(&content.file_path))?;
455
456        let mut content_objects: Vec<ContentObject> = serde_json::from_str(&content_json)?;
457
458        let new_obj: ContentObject = serde_json::from_str(object_json)?;
459
460        // todo: do more to verify the structure of the object is compatible with its instance_of
461
462        content_objects.push(new_obj);
463
464        let new_objects = serde_json::to_string_pretty(&content_objects)?;
465
466        let mut file = fs_err::File::create(proj_path.join(&content.file_path))?;
467        file.write_all(new_objects.as_bytes())?;
468    }
469
470    Ok(())
471}
472
473#[instrument(err)]
474pub fn add_model(path: &str, name: &str, uuid: Option<&str>) -> anyhow::Result<()> {
475    let proj_path = Path::new(path);
476    let ordinary_json = fs_err::read_to_string(proj_path.join("ordinary.json"))?;
477
478    let mut app_config: OrdinaryConfig = serde_json::from_str(&ordinary_json)?;
479
480    let mut models = app_config.models.unwrap_or_default();
481
482    if models.len() <= 255 {
483        let next_idx = models.len();
484
485        models.push(ModelConfig {
486            idx: u8::try_from(next_idx)?,
487            name: name.to_string(),
488            fields: vec![],
489            uuid: match uuid {
490                Some(uuid) => match uuid {
491                    "v4" => Some(UuidVersion::V4),
492                    "v7" => Some(UuidVersion::V7),
493                    _ => bail!("invalid UUID version"),
494                },
495                None => None,
496            },
497        });
498
499        app_config.models = Some(models);
500
501        let ordinary_json = serde_json::to_string_pretty(&app_config)?;
502
503        let mut file = fs_err::File::create(proj_path.join("ordinary.json"))?;
504        file.write_all(ordinary_json.as_bytes())?;
505    } else {
506        tracing::error!("cannot support more than 255 models for a single project.");
507    }
508
509    Ok(())
510}