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