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