Skip to content

Commit ec43b82

Browse files
Merge pull request #20 from rohas-dev/feat/type-def
feat(parser): add support for type definitions in schema and enhance code generation for DTOs across Rust, Python, and TypeScript
2 parents 6857f35 + b811fde commit ec43b82

14 files changed

Lines changed: 380 additions & 25 deletions

File tree

crates/rohas-cli/src/utils/file_util.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ pub fn parse_directory(dir: &PathBuf) -> anyhow::Result<Schema> {
5757
Ok(schema) => {
5858
// Merge schemas
5959
combined_schema.models.extend(schema.models);
60+
combined_schema.types.extend(schema.types);
6061
combined_schema.inputs.extend(schema.inputs);
6162
combined_schema.apis.extend(schema.apis);
6263
combined_schema.events.extend(schema.events);

crates/rohas-codegen/src/python.rs

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use crate::error::Result;
22
use crate::templates;
3-
use rohas_parser::{Api, Event, FieldType, Model, Schema, WebSocket};
3+
use rohas_parser::{Api, Event, FieldType, Model, Schema, Type, WebSocket};
44
use std::fs;
55
use std::path::Path;
66

@@ -58,14 +58,24 @@ pub fn generate_dtos(schema: &Schema, output_dir: &Path) -> Result<()> {
5858
fs::write(dto_dir.join(file_name), content)?;
5959
}
6060

61+
for type_def in &schema.types {
62+
let content = generate_model_content(&rohas_parser::Model {
63+
name: type_def.name.clone(),
64+
fields: type_def.fields.clone(),
65+
attributes: vec![],
66+
});
67+
let file_name = format!("{}.py", templates::to_snake_case(&type_def.name));
68+
fs::write(dto_dir.join(file_name), content)?;
69+
}
70+
6171
Ok(())
6272
}
6373

6474
pub fn generate_apis(schema: &Schema, output_dir: &Path) -> Result<()> {
6575
let api_dir = output_dir.join("generated/api");
6676

6777
for api in &schema.apis {
68-
let content = generate_api_content(api);
78+
let content = generate_api_content(api, schema);
6979
let file_name = format!("{}.py", templates::to_snake_case(&api.name));
7080
fs::write(api_dir.join(file_name), content)?;
7181
}
@@ -114,7 +124,7 @@ fn extract_path_params(path: &str) -> Vec<String> {
114124
params
115125
}
116126

117-
fn generate_api_content(api: &Api) -> String {
127+
fn generate_api_content(api: &Api, schema: &Schema) -> String {
118128
let mut content = String::new();
119129

120130
content.push_str("from pydantic import BaseModel\n");
@@ -125,11 +135,23 @@ fn generate_api_content(api: &Api) -> String {
125135

126136
let is_custom_type = matches!(response_field_type, FieldType::Custom(_));
127137
if is_custom_type {
128-
content.push_str(&format!(
129-
"from ..models.{} import {}\n",
130-
templates::to_snake_case(&api.response),
131-
api.response
132-
));
138+
// Check if it's a type (DTO) or a model
139+
let is_type = schema.types.iter().any(|t| t.name == api.response);
140+
let is_input = schema.inputs.iter().any(|i| i.name == api.response);
141+
142+
if is_type || is_input {
143+
content.push_str(&format!(
144+
"from ..dto.{} import {}\n",
145+
templates::to_snake_case(&api.response),
146+
api.response
147+
));
148+
} else {
149+
content.push_str(&format!(
150+
"from ..models.{} import {}\n",
151+
templates::to_snake_case(&api.response),
152+
api.response
153+
));
154+
}
133155
}
134156

135157
if let Some(body) = &api.body {

crates/rohas-codegen/src/rust.rs

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use crate::error::Result;
22
use crate::templates;
3-
use rohas_parser::{Api, Event, FieldType, Model, Schema, WebSocket};
3+
use rohas_parser::{Api, Event, FieldType, Model, Schema, Type, WebSocket};
44
use std::fs;
55
use std::path::Path;
66

@@ -89,13 +89,28 @@ pub fn generate_dtos(schema: &Schema, output_dir: &Path) -> Result<()> {
8989
fs::write(dto_dir.join(file_name), content)?;
9090
}
9191

92+
for type_def in &schema.types {
93+
let content = generate_model_content(&rohas_parser::Model {
94+
name: type_def.name.clone(),
95+
fields: type_def.fields.clone(),
96+
attributes: vec![],
97+
});
98+
let file_name = format!("{}.rs", templates::to_snake_case(&type_def.name));
99+
fs::write(dto_dir.join(file_name), content)?;
100+
}
101+
92102
let mut mod_content = String::new();
93103
mod_content.push_str("// Auto-generated module declarations\n");
94104
for input in &schema.inputs {
95105
let mod_name = templates::to_snake_case(&input.name);
96106
mod_content.push_str(&format!("pub mod {};\n", mod_name));
97107
mod_content.push_str(&format!("pub use {}::{};\n", mod_name, input.name));
98108
}
109+
for type_def in &schema.types {
110+
let mod_name = templates::to_snake_case(&type_def.name);
111+
mod_content.push_str(&format!("pub mod {};\n", mod_name));
112+
mod_content.push_str(&format!("pub use {}::{};\n", mod_name, type_def.name));
113+
}
99114
fs::write(dto_dir.join("mod.rs"), mod_content)?;
100115

101116
Ok(())
@@ -105,7 +120,7 @@ pub fn generate_apis(schema: &Schema, output_dir: &Path) -> Result<()> {
105120
let api_dir = output_dir.join("generated/api");
106121

107122
for api in &schema.apis {
108-
let content = generate_api_content(api);
123+
let content = generate_api_content(api, schema);
109124
let file_name = format!("{}.rs", templates::to_snake_case(&api.name));
110125
fs::write(api_dir.join(file_name), content)?;
111126
}
@@ -133,7 +148,7 @@ pub fn generate_apis(schema: &Schema, output_dir: &Path) -> Result<()> {
133148
Ok(())
134149
}
135150

136-
fn generate_api_content(api: &Api) -> String {
151+
fn generate_api_content(api: &Api, schema: &Schema) -> String {
137152
let mut content = String::new();
138153

139154
content.push_str("use serde::{Deserialize, Serialize};\n");
@@ -151,7 +166,15 @@ fn generate_api_content(api: &Api) -> String {
151166
let is_custom_response = matches!(response_field_type, rohas_parser::FieldType::Custom(_));
152167
if is_custom_response {
153168
let response_type_snake = templates::to_snake_case(&api.response);
154-
content.push_str(&format!("use crate::generated::models::{}::{};\n", response_type_snake, api.response));
169+
170+
let is_type = schema.types.iter().any(|t| t.name == api.response);
171+
let is_input = schema.inputs.iter().any(|i| i.name == api.response);
172+
173+
if is_type || is_input {
174+
content.push_str(&format!("use crate::generated::dto::{}::{};\n", response_type_snake, api.response));
175+
} else {
176+
content.push_str(&format!("use crate::generated::models::{}::{};\n", response_type_snake, api.response));
177+
}
155178
}
156179
content.push_str("\n");
157180

crates/rohas-codegen/src/typescript.rs

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use crate::error::Result;
22
use crate::templates;
3-
use rohas_parser::{Api, Event, FieldType, Model, Schema, WebSocket};
3+
use rohas_parser::{Api, Event, FieldType, Model, Schema, Type, WebSocket};
44
use std::fs;
55
use std::path::Path;
66

@@ -91,14 +91,24 @@ pub fn generate_dtos(schema: &Schema, output_dir: &Path) -> Result<()> {
9191
fs::write(dto_dir.join(file_name), content)?;
9292
}
9393

94+
for type_def in &schema.types {
95+
let content = generate_model_content(&rohas_parser::Model {
96+
name: type_def.name.clone(),
97+
fields: type_def.fields.clone(),
98+
attributes: vec![],
99+
});
100+
let file_name = format!("{}.ts", templates::to_snake_case(&type_def.name));
101+
fs::write(dto_dir.join(file_name), content)?;
102+
}
103+
94104
Ok(())
95105
}
96106

97107
pub fn generate_apis(schema: &Schema, output_dir: &Path) -> Result<()> {
98108
let api_dir = output_dir.join("generated/api");
99109

100110
for api in &schema.apis {
101-
let content = generate_api_content(api);
111+
let content = generate_api_content(api, schema);
102112
let file_name = format!("{}.ts", templates::to_snake_case(&api.name));
103113
fs::write(api_dir.join(file_name), content)?;
104114
}
@@ -117,7 +127,7 @@ pub fn generate_apis(schema: &Schema, output_dir: &Path) -> Result<()> {
117127
Ok(())
118128
}
119129

120-
fn generate_api_content(api: &Api) -> String {
130+
fn generate_api_content(api: &Api, schema: &Schema) -> String {
121131
let mut content = String::new();
122132

123133
content.push_str("import { z } from 'zod';\n");
@@ -129,12 +139,24 @@ fn generate_api_content(api: &Api) -> String {
129139
let response_is_primitive = is_primitive_type(&api.response);
130140

131141
if !response_is_primitive {
132-
content.push_str(&format!(
133-
"import {{ {}, {}Schema }} from '@generated/models/{}';\n",
134-
api.response,
135-
api.response,
136-
templates::to_snake_case(&api.response)
137-
));
142+
let is_type = schema.types.iter().any(|t| t.name == api.response);
143+
let is_input = schema.inputs.iter().any(|i| i.name == api.response);
144+
145+
if is_type || is_input {
146+
content.push_str(&format!(
147+
"import {{ {}, {}Schema }} from '@generated/dto/{}';\n",
148+
api.response,
149+
api.response,
150+
templates::to_snake_case(&api.response)
151+
));
152+
} else {
153+
content.push_str(&format!(
154+
"import {{ {}, {}Schema }} from '@generated/models/{}';\n",
155+
api.response,
156+
api.response,
157+
templates::to_snake_case(&api.response)
158+
));
159+
}
138160
}
139161

140162
if let Some(body) = &api.body {

crates/rohas-dev-server/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -949,6 +949,7 @@ fn parse_directory(dir: &PathBuf) -> anyhow::Result<Schema> {
949949
match Parser::parse_file(path) {
950950
Ok(schema) => {
951951
combined_schema.models.extend(schema.models);
952+
combined_schema.types.extend(schema.types);
952953
combined_schema.inputs.extend(schema.inputs);
953954
combined_schema.apis.extend(schema.apis);
954955
combined_schema.events.extend(schema.events);

crates/rohas-parser/src/ast.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize};
33
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
44
pub struct Schema {
55
pub models: Vec<Model>,
6+
pub types: Vec<Type>,
67
pub apis: Vec<Api>,
78
pub events: Vec<Event>,
89
pub crons: Vec<Cron>,
@@ -14,6 +15,7 @@ impl Schema {
1415
pub fn new() -> Self {
1516
Self {
1617
models: Vec::new(),
18+
types: Vec::new(),
1719
apis: Vec::new(),
1820
events: Vec::new(),
1921
crons: Vec::new(),
@@ -34,6 +36,15 @@ impl Schema {
3436
}
3537
}
3638

39+
for type_def in &self.types {
40+
if !names.insert(&type_def.name) {
41+
return Err(crate::ParseError::DuplicateDefinition(format!(
42+
"Type '{}'",
43+
type_def.name
44+
)));
45+
}
46+
}
47+
3748
for api in &self.apis {
3849
if !names.insert(&api.name) {
3950
return Err(crate::ParseError::DuplicateDefinition(format!(
@@ -219,6 +230,12 @@ pub struct Cron {
219230
pub triggers: Vec<String>,
220231
}
221232

233+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
234+
pub struct Type {
235+
pub name: String,
236+
pub fields: Vec<Field>,
237+
}
238+
222239
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
223240
pub struct Input {
224241
pub name: String,

crates/rohas-parser/src/parser.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ impl Parser {
3131
let model = Self::parse_model(inner_pair)?;
3232
schema.models.push(model);
3333
}
34+
Rule::type_def => {
35+
let type_def = Self::parse_type(inner_pair)?;
36+
schema.types.push(type_def);
37+
}
3438
Rule::api => {
3539
let api = Self::parse_api(inner_pair)?;
3640
schema.apis.push(api);
@@ -307,6 +311,46 @@ impl Parser {
307311
})
308312
}
309313

314+
fn parse_type(pair: pest::iterators::Pair<Rule>) -> Result<Type> {
315+
let mut inner = pair.into_inner();
316+
let name = inner
317+
.next()
318+
.ok_or_else(|| ParseError::InvalidModel("Missing type name".into()))?
319+
.as_str()
320+
.to_string();
321+
322+
let mut fields = Vec::new();
323+
324+
for field_pair in inner {
325+
if field_pair.as_rule() == Rule::input_field {
326+
let mut field_inner = field_pair.into_inner();
327+
328+
let field_name = field_inner
329+
.next()
330+
.ok_or_else(|| ParseError::InvalidModel("Missing field name".into()))?
331+
.as_str()
332+
.to_string();
333+
334+
let field_type_pair = field_inner
335+
.next()
336+
.ok_or_else(|| ParseError::InvalidModel("Missing field type".into()))?;
337+
338+
let field_type = Self::parse_field_type(field_type_pair)?;
339+
340+
let optional = field_inner.next().is_some();
341+
342+
fields.push(Field {
343+
name: field_name,
344+
field_type,
345+
optional,
346+
attributes: Vec::new(),
347+
});
348+
}
349+
}
350+
351+
Ok(Type { name, fields })
352+
}
353+
310354
fn parse_input(pair: pest::iterators::Pair<Rule>) -> Result<Input> {
311355
let mut inner = pair.into_inner();
312356
let name = inner

crates/rohas-parser/src/rohas.pest

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ WHITESPACE = _{ " " | "\t" | "\r" | "\n" }
44
COMMENT = _{ "//" ~ (!"\n" ~ ANY)* ~ "\n" | "/*" ~ (!"*/" ~ ANY)* ~ "*/" }
55

66
// Top-level schema
7-
schema = { SOI ~ (model | api | event | cron | input | ws)* ~ EOI }
7+
schema = { SOI ~ (model | type_def | api | event | cron | input | ws)* ~ EOI }
88

99
// Identifiers and literals
1010
ident = @{ ASCII_ALPHA ~ (ASCII_ALPHANUMERIC | "_")* }
@@ -59,6 +59,9 @@ cron_property = {
5959
| ("triggers:" ~ trigger_list)
6060
}
6161

62+
// Type definition (DTO for responses)
63+
type_def = { "type" ~ ident ~ "{" ~ input_field* ~ "}" }
64+
6265
// Input definition (DTO)
6366
input = { "input" ~ ident ~ "{" ~ input_field* ~ "}" }
6467
input_field = { ident ~ ":" ~ field_type ~ optional? }

examples/hello-world/schema/api/health.ro

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
model HealthResponse {
2-
status String
3-
timestamp String
1+
type HealthResponse {
2+
status: String
3+
timestamp: String
44
}
55

66
api Health {
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# EditorConfig is awesome: https://EditorConfig.org
2+
3+
root = true
4+
5+
[*]
6+
charset = utf-8
7+
end_of_line = lf
8+
insert_final_newline = true
9+
trim_trailing_whitespace = true
10+
11+
[*.{ts,tsx,js,jsx,json}]
12+
indent_style = space
13+
indent_size = 2
14+
15+
[*.{py}]
16+
indent_style = space
17+
indent_size = 4
18+
19+
[*.{yml,yaml}]
20+
indent_style = space
21+
indent_size = 2
22+
23+
[*.md]
24+
trim_trailing_whitespace = false

0 commit comments

Comments
 (0)