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