From b811fde4555d5870b932d3b5c2a8644dc4be3fe2 Mon Sep 17 00:00:00 2001 From: sophatvathana Date: Mon, 8 Dec 2025 01:52:04 +0700 Subject: [PATCH] feat(parser): add support for type definitions in schema and enhance code generation for DTOs across Rust, Python, and TypeScript --- crates/rohas-cli/src/utils/file_util.rs | 1 + crates/rohas-codegen/src/python.rs | 38 ++++++-- crates/rohas-codegen/src/rust.rs | 31 +++++- crates/rohas-codegen/src/typescript.rs | 40 ++++++-- crates/rohas-dev-server/src/lib.rs | 1 + crates/rohas-parser/src/ast.rs | 17 ++++ crates/rohas-parser/src/parser.rs | 44 +++++++++ crates/rohas-parser/src/rohas.pest | 5 +- examples/hello-world/schema/api/health.ro | 6 +- examples/hello-world/src/.editorconfig | 24 +++++ examples/hello-world/src/.gitignore | 52 +++++++++++ examples/hello-world/src/README.md | 109 ++++++++++++++++++++++ examples/hello-world/src/pyproject.toml | 31 ++++++ examples/hello-world/src/requirements.txt | 6 ++ 14 files changed, 380 insertions(+), 25 deletions(-) create mode 100644 examples/hello-world/src/.editorconfig create mode 100644 examples/hello-world/src/.gitignore create mode 100644 examples/hello-world/src/README.md create mode 100644 examples/hello-world/src/pyproject.toml create mode 100644 examples/hello-world/src/requirements.txt diff --git a/crates/rohas-cli/src/utils/file_util.rs b/crates/rohas-cli/src/utils/file_util.rs index 0448b8b..27de351 100644 --- a/crates/rohas-cli/src/utils/file_util.rs +++ b/crates/rohas-cli/src/utils/file_util.rs @@ -57,6 +57,7 @@ pub fn parse_directory(dir: &PathBuf) -> anyhow::Result { Ok(schema) => { // Merge schemas combined_schema.models.extend(schema.models); + combined_schema.types.extend(schema.types); combined_schema.inputs.extend(schema.inputs); combined_schema.apis.extend(schema.apis); combined_schema.events.extend(schema.events); diff --git a/crates/rohas-codegen/src/python.rs b/crates/rohas-codegen/src/python.rs index f0fc686..1533a9d 100644 --- a/crates/rohas-codegen/src/python.rs +++ b/crates/rohas-codegen/src/python.rs @@ -1,6 +1,6 @@ use crate::error::Result; use crate::templates; -use rohas_parser::{Api, Event, FieldType, Model, Schema, WebSocket}; +use rohas_parser::{Api, Event, FieldType, Model, Schema, Type, WebSocket}; use std::fs; use std::path::Path; @@ -58,6 +58,16 @@ pub fn generate_dtos(schema: &Schema, output_dir: &Path) -> Result<()> { fs::write(dto_dir.join(file_name), content)?; } + for type_def in &schema.types { + let content = generate_model_content(&rohas_parser::Model { + name: type_def.name.clone(), + fields: type_def.fields.clone(), + attributes: vec![], + }); + let file_name = format!("{}.py", templates::to_snake_case(&type_def.name)); + fs::write(dto_dir.join(file_name), content)?; + } + Ok(()) } @@ -65,7 +75,7 @@ pub fn generate_apis(schema: &Schema, output_dir: &Path) -> Result<()> { let api_dir = output_dir.join("generated/api"); for api in &schema.apis { - let content = generate_api_content(api); + let content = generate_api_content(api, schema); let file_name = format!("{}.py", templates::to_snake_case(&api.name)); fs::write(api_dir.join(file_name), content)?; } @@ -114,7 +124,7 @@ fn extract_path_params(path: &str) -> Vec { params } -fn generate_api_content(api: &Api) -> String { +fn generate_api_content(api: &Api, schema: &Schema) -> String { let mut content = String::new(); content.push_str("from pydantic import BaseModel\n"); @@ -125,11 +135,23 @@ fn generate_api_content(api: &Api) -> String { let is_custom_type = matches!(response_field_type, FieldType::Custom(_)); if is_custom_type { - content.push_str(&format!( - "from ..models.{} import {}\n", - templates::to_snake_case(&api.response), - api.response - )); + // Check if it's a type (DTO) or a model + let is_type = schema.types.iter().any(|t| t.name == api.response); + let is_input = schema.inputs.iter().any(|i| i.name == api.response); + + if is_type || is_input { + content.push_str(&format!( + "from ..dto.{} import {}\n", + templates::to_snake_case(&api.response), + api.response + )); + } else { + content.push_str(&format!( + "from ..models.{} import {}\n", + templates::to_snake_case(&api.response), + api.response + )); + } } if let Some(body) = &api.body { diff --git a/crates/rohas-codegen/src/rust.rs b/crates/rohas-codegen/src/rust.rs index 33970cc..ffda39d 100644 --- a/crates/rohas-codegen/src/rust.rs +++ b/crates/rohas-codegen/src/rust.rs @@ -1,6 +1,6 @@ use crate::error::Result; use crate::templates; -use rohas_parser::{Api, Event, FieldType, Model, Schema, WebSocket}; +use rohas_parser::{Api, Event, FieldType, Model, Schema, Type, WebSocket}; use std::fs; use std::path::Path; @@ -89,6 +89,16 @@ pub fn generate_dtos(schema: &Schema, output_dir: &Path) -> Result<()> { fs::write(dto_dir.join(file_name), content)?; } + for type_def in &schema.types { + let content = generate_model_content(&rohas_parser::Model { + name: type_def.name.clone(), + fields: type_def.fields.clone(), + attributes: vec![], + }); + let file_name = format!("{}.rs", templates::to_snake_case(&type_def.name)); + fs::write(dto_dir.join(file_name), content)?; + } + let mut mod_content = String::new(); mod_content.push_str("// Auto-generated module declarations\n"); for input in &schema.inputs { @@ -96,6 +106,11 @@ pub fn generate_dtos(schema: &Schema, output_dir: &Path) -> Result<()> { mod_content.push_str(&format!("pub mod {};\n", mod_name)); mod_content.push_str(&format!("pub use {}::{};\n", mod_name, input.name)); } + for type_def in &schema.types { + let mod_name = templates::to_snake_case(&type_def.name); + mod_content.push_str(&format!("pub mod {};\n", mod_name)); + mod_content.push_str(&format!("pub use {}::{};\n", mod_name, type_def.name)); + } fs::write(dto_dir.join("mod.rs"), mod_content)?; Ok(()) @@ -105,7 +120,7 @@ pub fn generate_apis(schema: &Schema, output_dir: &Path) -> Result<()> { let api_dir = output_dir.join("generated/api"); for api in &schema.apis { - let content = generate_api_content(api); + let content = generate_api_content(api, schema); let file_name = format!("{}.rs", templates::to_snake_case(&api.name)); fs::write(api_dir.join(file_name), content)?; } @@ -133,7 +148,7 @@ pub fn generate_apis(schema: &Schema, output_dir: &Path) -> Result<()> { Ok(()) } -fn generate_api_content(api: &Api) -> String { +fn generate_api_content(api: &Api, schema: &Schema) -> String { let mut content = String::new(); content.push_str("use serde::{Deserialize, Serialize};\n"); @@ -151,7 +166,15 @@ fn generate_api_content(api: &Api) -> String { let is_custom_response = matches!(response_field_type, rohas_parser::FieldType::Custom(_)); if is_custom_response { let response_type_snake = templates::to_snake_case(&api.response); - content.push_str(&format!("use crate::generated::models::{}::{};\n", response_type_snake, api.response)); + + let is_type = schema.types.iter().any(|t| t.name == api.response); + let is_input = schema.inputs.iter().any(|i| i.name == api.response); + + if is_type || is_input { + content.push_str(&format!("use crate::generated::dto::{}::{};\n", response_type_snake, api.response)); + } else { + content.push_str(&format!("use crate::generated::models::{}::{};\n", response_type_snake, api.response)); + } } content.push_str("\n"); diff --git a/crates/rohas-codegen/src/typescript.rs b/crates/rohas-codegen/src/typescript.rs index 9b3f7e0..d1420d2 100644 --- a/crates/rohas-codegen/src/typescript.rs +++ b/crates/rohas-codegen/src/typescript.rs @@ -1,6 +1,6 @@ use crate::error::Result; use crate::templates; -use rohas_parser::{Api, Event, FieldType, Model, Schema, WebSocket}; +use rohas_parser::{Api, Event, FieldType, Model, Schema, Type, WebSocket}; use std::fs; use std::path::Path; @@ -91,6 +91,16 @@ pub fn generate_dtos(schema: &Schema, output_dir: &Path) -> Result<()> { fs::write(dto_dir.join(file_name), content)?; } + for type_def in &schema.types { + let content = generate_model_content(&rohas_parser::Model { + name: type_def.name.clone(), + fields: type_def.fields.clone(), + attributes: vec![], + }); + let file_name = format!("{}.ts", templates::to_snake_case(&type_def.name)); + fs::write(dto_dir.join(file_name), content)?; + } + Ok(()) } @@ -98,7 +108,7 @@ pub fn generate_apis(schema: &Schema, output_dir: &Path) -> Result<()> { let api_dir = output_dir.join("generated/api"); for api in &schema.apis { - let content = generate_api_content(api); + let content = generate_api_content(api, schema); let file_name = format!("{}.ts", templates::to_snake_case(&api.name)); fs::write(api_dir.join(file_name), content)?; } @@ -117,7 +127,7 @@ pub fn generate_apis(schema: &Schema, output_dir: &Path) -> Result<()> { Ok(()) } -fn generate_api_content(api: &Api) -> String { +fn generate_api_content(api: &Api, schema: &Schema) -> String { let mut content = String::new(); content.push_str("import { z } from 'zod';\n"); @@ -129,12 +139,24 @@ fn generate_api_content(api: &Api) -> String { let response_is_primitive = is_primitive_type(&api.response); if !response_is_primitive { - content.push_str(&format!( - "import {{ {}, {}Schema }} from '@generated/models/{}';\n", - api.response, - api.response, - templates::to_snake_case(&api.response) - )); + let is_type = schema.types.iter().any(|t| t.name == api.response); + let is_input = schema.inputs.iter().any(|i| i.name == api.response); + + if is_type || is_input { + content.push_str(&format!( + "import {{ {}, {}Schema }} from '@generated/dto/{}';\n", + api.response, + api.response, + templates::to_snake_case(&api.response) + )); + } else { + content.push_str(&format!( + "import {{ {}, {}Schema }} from '@generated/models/{}';\n", + api.response, + api.response, + templates::to_snake_case(&api.response) + )); + } } if let Some(body) = &api.body { diff --git a/crates/rohas-dev-server/src/lib.rs b/crates/rohas-dev-server/src/lib.rs index 0e80b0b..b1d809d 100644 --- a/crates/rohas-dev-server/src/lib.rs +++ b/crates/rohas-dev-server/src/lib.rs @@ -949,6 +949,7 @@ fn parse_directory(dir: &PathBuf) -> anyhow::Result { match Parser::parse_file(path) { Ok(schema) => { combined_schema.models.extend(schema.models); + combined_schema.types.extend(schema.types); combined_schema.inputs.extend(schema.inputs); combined_schema.apis.extend(schema.apis); combined_schema.events.extend(schema.events); diff --git a/crates/rohas-parser/src/ast.rs b/crates/rohas-parser/src/ast.rs index cdcc7aa..08581c9 100644 --- a/crates/rohas-parser/src/ast.rs +++ b/crates/rohas-parser/src/ast.rs @@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct Schema { pub models: Vec, + pub types: Vec, pub apis: Vec, pub events: Vec, pub crons: Vec, @@ -14,6 +15,7 @@ impl Schema { pub fn new() -> Self { Self { models: Vec::new(), + types: Vec::new(), apis: Vec::new(), events: Vec::new(), crons: Vec::new(), @@ -34,6 +36,15 @@ impl Schema { } } + for type_def in &self.types { + if !names.insert(&type_def.name) { + return Err(crate::ParseError::DuplicateDefinition(format!( + "Type '{}'", + type_def.name + ))); + } + } + for api in &self.apis { if !names.insert(&api.name) { return Err(crate::ParseError::DuplicateDefinition(format!( @@ -219,6 +230,12 @@ pub struct Cron { pub triggers: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Type { + pub name: String, + pub fields: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct Input { pub name: String, diff --git a/crates/rohas-parser/src/parser.rs b/crates/rohas-parser/src/parser.rs index db8e936..0e89f59 100644 --- a/crates/rohas-parser/src/parser.rs +++ b/crates/rohas-parser/src/parser.rs @@ -31,6 +31,10 @@ impl Parser { let model = Self::parse_model(inner_pair)?; schema.models.push(model); } + Rule::type_def => { + let type_def = Self::parse_type(inner_pair)?; + schema.types.push(type_def); + } Rule::api => { let api = Self::parse_api(inner_pair)?; schema.apis.push(api); @@ -307,6 +311,46 @@ impl Parser { }) } + fn parse_type(pair: pest::iterators::Pair) -> Result { + let mut inner = pair.into_inner(); + let name = inner + .next() + .ok_or_else(|| ParseError::InvalidModel("Missing type name".into()))? + .as_str() + .to_string(); + + let mut fields = Vec::new(); + + for field_pair in inner { + if field_pair.as_rule() == Rule::input_field { + let mut field_inner = field_pair.into_inner(); + + let field_name = field_inner + .next() + .ok_or_else(|| ParseError::InvalidModel("Missing field name".into()))? + .as_str() + .to_string(); + + let field_type_pair = field_inner + .next() + .ok_or_else(|| ParseError::InvalidModel("Missing field type".into()))?; + + let field_type = Self::parse_field_type(field_type_pair)?; + + let optional = field_inner.next().is_some(); + + fields.push(Field { + name: field_name, + field_type, + optional, + attributes: Vec::new(), + }); + } + } + + Ok(Type { name, fields }) + } + fn parse_input(pair: pest::iterators::Pair) -> Result { let mut inner = pair.into_inner(); let name = inner diff --git a/crates/rohas-parser/src/rohas.pest b/crates/rohas-parser/src/rohas.pest index 55be8a4..e501a81 100644 --- a/crates/rohas-parser/src/rohas.pest +++ b/crates/rohas-parser/src/rohas.pest @@ -4,7 +4,7 @@ WHITESPACE = _{ " " | "\t" | "\r" | "\n" } COMMENT = _{ "//" ~ (!"\n" ~ ANY)* ~ "\n" | "/*" ~ (!"*/" ~ ANY)* ~ "*/" } // Top-level schema -schema = { SOI ~ (model | api | event | cron | input | ws)* ~ EOI } +schema = { SOI ~ (model | type_def | api | event | cron | input | ws)* ~ EOI } // Identifiers and literals ident = @{ ASCII_ALPHA ~ (ASCII_ALPHANUMERIC | "_")* } @@ -59,6 +59,9 @@ cron_property = { | ("triggers:" ~ trigger_list) } +// Type definition (DTO for responses) +type_def = { "type" ~ ident ~ "{" ~ input_field* ~ "}" } + // Input definition (DTO) input = { "input" ~ ident ~ "{" ~ input_field* ~ "}" } input_field = { ident ~ ":" ~ field_type ~ optional? } diff --git a/examples/hello-world/schema/api/health.ro b/examples/hello-world/schema/api/health.ro index 8a1948a..6e9e2eb 100644 --- a/examples/hello-world/schema/api/health.ro +++ b/examples/hello-world/schema/api/health.ro @@ -1,6 +1,6 @@ -model HealthResponse { - status String - timestamp String +type HealthResponse { + status: String + timestamp: String } api Health { diff --git a/examples/hello-world/src/.editorconfig b/examples/hello-world/src/.editorconfig new file mode 100644 index 0000000..22777ef --- /dev/null +++ b/examples/hello-world/src/.editorconfig @@ -0,0 +1,24 @@ +# EditorConfig is awesome: https://EditorConfig.org + +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{ts,tsx,js,jsx,json}] +indent_style = space +indent_size = 2 + +[*.{py}] +indent_style = space +indent_size = 4 + +[*.{yml,yaml}] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/examples/hello-world/src/.gitignore b/examples/hello-world/src/.gitignore new file mode 100644 index 0000000..bdf3be8 --- /dev/null +++ b/examples/hello-world/src/.gitignore @@ -0,0 +1,52 @@ +# Dependencies +node_modules/ +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +env/ +venv/ +ENV/ +.venv/ + +# Build outputs +dist/ +build/ +*.egg-info/ +.tsbuildinfo + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Logs +*.log +logs/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Environment variables +.env +.env.local +.env.*.local + +# OS +.DS_Store +Thumbs.db + +# Testing +coverage/ +.coverage +.pytest_cache/ +*.cover +.hypothesis/ + +# Rohas compiled output +.rohas/ +src/generated/ diff --git a/examples/hello-world/src/README.md b/examples/hello-world/src/README.md new file mode 100644 index 0000000..9e3e416 --- /dev/null +++ b/examples/hello-world/src/README.md @@ -0,0 +1,109 @@ +# src + +Rohas event-driven application + +## Project Structure + +``` +├── schema/ # Schema definitions (.ro files) +│ ├── api/ # API endpoint schemas +│ ├── events/ # Event schemas +│ ├── models/ # Data model schemas +│ └── cron/ # Cron job schemas +├── src/ +│ ├── generated/ # Auto-generated types (DO NOT EDIT) +│ └── handlers/ # Your handler implementations +│ ├── api/ # API handlers +│ ├── events/ # Event handlers +│ └── cron/ # Cron job handlers +└── config/ # Configuration files +``` + +## Getting Started + +### Installation + +```bash +# Install dependencies (TypeScript) +npm install + +# Or for Python +pip install -r requirements.txt +``` + +### Development + +```bash +# Generate code from schema +rohas codegen + +# Start development server +rohas dev + +# Validate schema +rohas validate +``` + +## Schema Overview + + +### APIs + +- `GET /health` - Health +- `POST /users` - CreateUser +- `GET /test` - Test +- `GET /timeline/fast` - TimelineTestFast +- `GET /timeline/slow` - TimelineTestSlow +- `GET /timeline/very-slow` - TimelineTestVerySlow +- `GET /timeline/multi-step` - TimelineTestMultiStep + +### Events + +- `FastCompleted` - Payload: Json +- `SlowCompleted` - Payload: Json +- `VerySlowCompleted` - Payload: Json +- `BottleneckDetected` - Payload: Json +- `MajorBottleneckDetected` - Payload: Json +- `ValidationComplete` - Payload: Json +- `ProcessingComplete` - Payload: Json +- `ExternalCallComplete` - Payload: Json +- `FinalizationComplete` - Payload: Json +- `CleanupStep1` - Payload: Json +- `CleanupStep2` - Payload: Json +- `BottleneckLogged` - Payload: Json +- `WelcomeEmailSent` - Payload: Json +- `UserCreated` - Payload: User +- `ManualTrigger` - Payload: String + +### Cron Jobs + +- `DailyCleanup` - Schedule: 0 */5 * * * * + + +## Handler Naming Convention + +Handler files must be named exactly as the API/Event/Cron name in the schema: + +- API `Health` → `src/handlers/api/Health.ts` +- Event `UserCreated` → Handler defined in event schema +- Cron `DailyCleanup` → `src/handlers/cron/DailyCleanup.ts` + +## Generated Code + +The `src/generated/` directory contains auto-generated TypeScript types and interfaces. +**DO NOT EDIT** these files manually - they will be regenerated when you run `rohas codegen`. + +## Adding New Features + +1. Define your schema in `schema/` directory +2. Run `rohas codegen` to generate types and handler stubs +3. Implement your handler logic in `src/handlers/` +4. Test with `rohas dev` + +## Configuration + +See `config/rohas.toml` for project configuration. + +## License + +MIT diff --git a/examples/hello-world/src/pyproject.toml b/examples/hello-world/src/pyproject.toml new file mode 100644 index 0000000..5495980 --- /dev/null +++ b/examples/hello-world/src/pyproject.toml @@ -0,0 +1,31 @@ +[project] +name = "src" +version = "0.1.0" +description = "Rohas event-driven application" +requires-python = ">=3.9" +dependencies = [ + "pydantic>=2.0.0", + "typing-extensions>=4.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "black>=23.0.0", + "mypy>=1.0.0", + "ruff>=0.1.0", +] + +[tool.black] +line-length = 100 +target-version = ['py39', 'py310', 'py311'] + +[tool.mypy] +python_version = "3.9" +strict = true +warn_return_any = true +warn_unused_configs = true + +[tool.ruff] +line-length = 100 +target-version = "py39" diff --git a/examples/hello-world/src/requirements.txt b/examples/hello-world/src/requirements.txt new file mode 100644 index 0000000..34973ad --- /dev/null +++ b/examples/hello-world/src/requirements.txt @@ -0,0 +1,6 @@ +# Python dependencies for Rohas project +# Add your project-specific dependencies here + +# Common dependencies +pydantic>=2.0.0 +typing-extensions>=4.0.0