1use shaperail_core::{FieldType, ResourceDefinition};
7
8fn field_type_to_proto(ft: &FieldType) -> &'static str {
10 match ft {
11 FieldType::Uuid => "string",
12 FieldType::String => "string",
13 FieldType::Integer => "int64",
14 FieldType::Number => "double",
15 FieldType::Boolean => "bool",
16 FieldType::Timestamp => "google.protobuf.Timestamp",
17 FieldType::Date => "string",
18 FieldType::Enum => "string",
19 FieldType::Json => "google.protobuf.Struct",
20 FieldType::Array => "google.protobuf.ListValue",
21 FieldType::File => "string",
22 }
23}
24
25pub fn needs_wkt_import(ft: &FieldType) -> bool {
27 matches!(
28 ft,
29 FieldType::Timestamp | FieldType::Json | FieldType::Array
30 )
31}
32
33fn to_pascal_case(s: &str) -> String {
35 s.split('_')
36 .map(|part| {
37 let mut chars = part.chars();
38 match chars.next() {
39 Some(c) => {
40 let upper: String = c.to_uppercase().collect();
41 upper + chars.as_str()
42 }
43 None => String::new(),
44 }
45 })
46 .collect()
47}
48
49fn to_singular(s: &str) -> String {
52 const EXCEPTIONS: &[&str] = &["status", "bus", "alias", "canvas"];
54 if EXCEPTIONS.iter().any(|e| s.ends_with(e)) {
55 return s.to_string();
56 }
57
58 if let Some(stripped) = s.strip_suffix("ies") {
59 format!("{stripped}y")
60 } else if s.ends_with("ses") || s.ends_with("xes") || s.ends_with("zes") {
61 s[..s.len() - 2].to_string()
63 } else if let Some(stripped) = s.strip_suffix('s') {
64 if stripped.ends_with('s') {
65 s.to_string()
66 } else {
67 stripped.to_string()
68 }
69 } else {
70 s.to_string()
71 }
72}
73
74pub fn generate_proto(resource: &ResourceDefinition) -> String {
83 let resource_name = &resource.resource;
84 let singular = to_singular(resource_name);
85 let pascal = to_pascal_case(&singular);
86 let pascal_plural = to_pascal_case(resource_name);
87 let version = resource.version;
88
89 let mut needs_timestamp = false;
90 let mut needs_struct = false;
91
92 for field in resource.schema.values() {
94 if matches!(field.field_type, FieldType::Timestamp) {
95 needs_timestamp = true;
96 }
97 if matches!(field.field_type, FieldType::Json | FieldType::Array) {
98 needs_struct = true;
99 }
100 }
101
102 let mut proto = String::new();
103 proto.push_str("syntax = \"proto3\";\n\n");
104 proto.push_str(&format!(
105 "package shaperail.v{version}.{resource_name};\n\n"
106 ));
107
108 if needs_timestamp {
109 proto.push_str("import \"google/protobuf/timestamp.proto\";\n");
110 }
111 if needs_struct {
112 proto.push_str("import \"google/protobuf/struct.proto\";\n");
113 }
114 if needs_timestamp || needs_struct {
115 proto.push('\n');
116 }
117
118 proto.push_str(&format!("// {pascal} resource message.\n"));
120 proto.push_str(&format!("message {pascal} {{\n"));
121 for (i, (field_name, field_schema)) in resource.schema.iter().enumerate() {
122 let proto_type = field_type_to_proto(&field_schema.field_type);
123 let field_num = i + 1;
124 if field_schema.field_type == FieldType::Enum {
125 if let Some(ref values) = field_schema.values {
126 proto.push_str(&format!(" // Allowed values: {}\n", values.join(", ")));
127 }
128 }
129 proto.push_str(&format!(" {proto_type} {field_name} = {field_num};\n"));
130 }
131 proto.push_str("}\n\n");
132
133 let endpoints = resource.endpoints.as_ref();
135 let has_list = endpoints.and_then(|e| e.get("list")).is_some();
136 let has_get = endpoints.and_then(|e| e.get("get")).is_some();
137 let has_create = endpoints.and_then(|e| e.get("create")).is_some();
138 let has_update = endpoints.and_then(|e| e.get("update")).is_some();
139 let has_delete = endpoints.and_then(|e| e.get("delete")).is_some();
140
141 if has_list {
143 proto.push_str(&format!("message List{pascal_plural}Request {{\n"));
144 if let Some(ep) = endpoints.and_then(|e| e.get("list")) {
146 let mut field_num = 1;
147 if let Some(ref filters) = ep.filters {
148 for f in filters {
149 proto.push_str(&format!(" string {f} = {field_num};\n"));
150 field_num += 1;
151 }
152 }
153 if ep.search.is_some() {
154 proto.push_str(&format!(" string search = {field_num};\n"));
155 field_num += 1;
156 }
157 proto.push_str(&format!(" string cursor = {field_num};\n"));
158 field_num += 1;
159 proto.push_str(&format!(" int32 page_size = {field_num};\n"));
160 field_num += 1;
161 proto.push_str(&format!(" string sort = {field_num};\n"));
162 }
163 proto.push_str("}\n\n");
164
165 proto.push_str(&format!("message List{pascal_plural}Response {{\n"));
166 proto.push_str(&format!(" repeated {pascal} items = 1;\n"));
167 proto.push_str(" string next_cursor = 2;\n");
168 proto.push_str(" bool has_more = 3;\n");
169 proto.push_str(" int64 total = 4;\n");
170 proto.push_str("}\n\n");
171 }
172
173 if has_get {
175 proto.push_str(&format!("message Get{pascal}Request {{\n"));
176 proto.push_str(" string id = 1;\n");
177 proto.push_str("}\n\n");
178
179 proto.push_str(&format!("message Get{pascal}Response {{\n"));
180 proto.push_str(&format!(" {pascal} data = 1;\n"));
181 proto.push_str("}\n\n");
182 }
183
184 if has_create {
186 proto.push_str(&format!("message Create{pascal}Request {{\n"));
187 if let Some(ep) = endpoints.and_then(|e| e.get("create")) {
188 if let Some(ref input) = ep.input {
189 for (i, field_name) in input.iter().enumerate() {
190 let proto_type = resource
191 .schema
192 .get(field_name.as_str())
193 .map(|f| field_type_to_proto(&f.field_type))
194 .unwrap_or("string");
195 proto.push_str(&format!(" {proto_type} {field_name} = {};\n", i + 1));
196 }
197 }
198 }
199 proto.push_str("}\n\n");
200
201 proto.push_str(&format!("message Create{pascal}Response {{\n"));
202 proto.push_str(&format!(" {pascal} data = 1;\n"));
203 proto.push_str("}\n\n");
204 }
205
206 if has_update {
208 proto.push_str(&format!("message Update{pascal}Request {{\n"));
209 proto.push_str(" string id = 1;\n");
210 if let Some(ep) = endpoints.and_then(|e| e.get("update")) {
211 if let Some(ref input) = ep.input {
212 for (i, field_name) in input.iter().enumerate() {
213 let proto_type = resource
214 .schema
215 .get(field_name.as_str())
216 .map(|f| field_type_to_proto(&f.field_type))
217 .unwrap_or("string");
218 proto.push_str(&format!(" {proto_type} {field_name} = {};\n", i + 2));
219 }
220 }
221 }
222 proto.push_str("}\n\n");
223
224 proto.push_str(&format!("message Update{pascal}Response {{\n"));
225 proto.push_str(&format!(" {pascal} data = 1;\n"));
226 proto.push_str("}\n\n");
227 }
228
229 if has_delete {
231 proto.push_str(&format!("message Delete{pascal}Request {{\n"));
232 proto.push_str(" string id = 1;\n");
233 proto.push_str("}\n\n");
234
235 proto.push_str(&format!("message Delete{pascal}Response {{\n"));
236 proto.push_str(" bool success = 1;\n");
237 proto.push_str("}\n\n");
238 }
239
240 proto.push_str(&format!(
242 "// gRPC service for {resource_name} (v{version}).\n"
243 ));
244 proto.push_str(&format!("service {pascal}Service {{\n"));
245
246 if has_list {
247 proto.push_str(&format!(
248 " // Lists {resource_name} with filters, pagination, and sorting.\n"
249 ));
250 proto.push_str(&format!(
251 " rpc List{pascal_plural}(List{pascal_plural}Request) returns (List{pascal_plural}Response);\n\n"
252 ));
253 proto.push_str(&format!(
254 " // Streams {resource_name} matching the request filters.\n"
255 ));
256 proto.push_str(&format!(
257 " rpc Stream{pascal_plural}(List{pascal_plural}Request) returns (stream {pascal});\n\n"
258 ));
259 }
260
261 if has_get {
262 proto.push_str(&format!(" // Gets a single {singular} by ID.\n"));
263 proto.push_str(&format!(
264 " rpc Get{pascal}(Get{pascal}Request) returns (Get{pascal}Response);\n\n"
265 ));
266 }
267
268 if has_create {
269 proto.push_str(&format!(" // Creates a new {singular}.\n"));
270 proto.push_str(&format!(
271 " rpc Create{pascal}(Create{pascal}Request) returns (Create{pascal}Response);\n\n"
272 ));
273 }
274
275 if has_update {
276 proto.push_str(&format!(" // Updates an existing {singular}.\n"));
277 proto.push_str(&format!(
278 " rpc Update{pascal}(Update{pascal}Request) returns (Update{pascal}Response);\n\n"
279 ));
280 }
281
282 if has_delete {
283 proto.push_str(&format!(" // Deletes a {singular} by ID.\n"));
284 proto.push_str(&format!(
285 " rpc Delete{pascal}(Delete{pascal}Request) returns (Delete{pascal}Response);\n"
286 ));
287 }
288
289 proto.push_str("}\n");
290
291 proto
292}
293
294pub fn generate_all_protos(resources: &[ResourceDefinition]) -> Vec<(String, String)> {
296 resources
297 .iter()
298 .map(|r| {
299 let filename = format!("{}.proto", r.resource);
300 let content = generate_proto(r);
301 (filename, content)
302 })
303 .collect()
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309
310 #[test]
311 fn to_pascal_case_simple() {
312 assert_eq!(to_pascal_case("users"), "Users");
313 assert_eq!(to_pascal_case("blog_posts"), "BlogPosts");
314 assert_eq!(to_pascal_case("api_keys"), "ApiKeys");
315 }
316
317 #[test]
318 fn to_singular_simple() {
319 assert_eq!(to_singular("users"), "user");
320 assert_eq!(to_singular("blog_posts"), "blog_post");
321 assert_eq!(to_singular("categories"), "category");
322 assert_eq!(to_singular("addresses"), "address");
323 assert_eq!(to_singular("status"), "status");
324 }
325
326 #[test]
327 fn field_type_mapping() {
328 assert_eq!(field_type_to_proto(&FieldType::Uuid), "string");
329 assert_eq!(field_type_to_proto(&FieldType::String), "string");
330 assert_eq!(field_type_to_proto(&FieldType::Integer), "int64");
331 assert_eq!(field_type_to_proto(&FieldType::Number), "double");
332 assert_eq!(field_type_to_proto(&FieldType::Boolean), "bool");
333 assert_eq!(
334 field_type_to_proto(&FieldType::Timestamp),
335 "google.protobuf.Timestamp"
336 );
337 assert_eq!(field_type_to_proto(&FieldType::Date), "string");
338 assert_eq!(field_type_to_proto(&FieldType::Enum), "string");
339 assert_eq!(
340 field_type_to_proto(&FieldType::Json),
341 "google.protobuf.Struct"
342 );
343 }
344
345 #[test]
346 fn wkt_import_detection() {
347 assert!(needs_wkt_import(&FieldType::Timestamp));
348 assert!(needs_wkt_import(&FieldType::Json));
349 assert!(needs_wkt_import(&FieldType::Array));
350 assert!(!needs_wkt_import(&FieldType::String));
351 assert!(!needs_wkt_import(&FieldType::Uuid));
352 }
353
354 use indexmap::IndexMap;
355 use shaperail_core::{EndpointSpec, FieldSchema, HttpMethod};
356
357 fn field(ft: FieldType) -> FieldSchema {
358 FieldSchema {
359 field_type: ft,
360 primary: false,
361 generated: false,
362 required: false,
363 unique: false,
364 nullable: false,
365 reference: None,
366 min: None,
367 max: None,
368 format: None,
369 values: None,
370 default: None,
371 sensitive: false,
372 search: false,
373 items: None,
374 transient: false,
375 }
376 }
377
378 fn endpoint(method: HttpMethod, path: &str) -> EndpointSpec {
379 EndpointSpec {
380 method: Some(method),
381 path: Some(path.to_string()),
382 ..Default::default()
383 }
384 }
385
386 #[test]
387 fn generate_proto_basic_resource() {
388 let mut schema = IndexMap::new();
389 schema.insert(
390 "id".to_string(),
391 FieldSchema {
392 primary: true,
393 generated: true,
394 ..field(FieldType::Uuid)
395 },
396 );
397 schema.insert(
398 "name".to_string(),
399 FieldSchema {
400 required: true,
401 ..field(FieldType::String)
402 },
403 );
404 schema.insert("active".to_string(), field(FieldType::Boolean));
405
406 let mut endpoints = IndexMap::new();
407 endpoints.insert(
408 "list".to_string(),
409 EndpointSpec {
410 filters: Some(vec!["active".to_string()]),
411 search: Some(vec!["name".to_string()]),
412 ..endpoint(HttpMethod::Get, "/items")
413 },
414 );
415 endpoints.insert("get".to_string(), endpoint(HttpMethod::Get, "/items/:id"));
416 endpoints.insert(
417 "create".to_string(),
418 EndpointSpec {
419 input: Some(vec!["name".to_string(), "active".to_string()]),
420 ..endpoint(HttpMethod::Post, "/items")
421 },
422 );
423 endpoints.insert(
424 "delete".to_string(),
425 endpoint(HttpMethod::Delete, "/items/:id"),
426 );
427
428 let resource = ResourceDefinition {
429 resource: "items".to_string(),
430 version: 1,
431 db: None,
432 tenant_key: None,
433 schema,
434 endpoints: Some(endpoints),
435 relations: None,
436 indexes: None,
437 };
438
439 let proto = generate_proto(&resource);
440
441 assert!(proto.contains("syntax = \"proto3\";"));
442 assert!(proto.contains("package shaperail.v1.items;"));
443 assert!(proto.contains("message Item {"));
444 assert!(proto.contains("string id = 1;"));
445 assert!(proto.contains("string name = 2;"));
446 assert!(proto.contains("bool active = 3;"));
447 assert!(proto.contains("service ItemService {"));
448 assert!(proto.contains("rpc ListItems(ListItemsRequest) returns (ListItemsResponse);"));
449 assert!(proto.contains("rpc StreamItems(ListItemsRequest) returns (stream Item);"));
450 assert!(proto.contains("rpc GetItem(GetItemRequest) returns (GetItemResponse);"));
451 assert!(proto.contains("rpc CreateItem(CreateItemRequest) returns (CreateItemResponse);"));
452 assert!(proto.contains("rpc DeleteItem(DeleteItemRequest) returns (DeleteItemResponse);"));
453 assert!(proto.contains("string active = 1;"));
454 assert!(proto.contains("string search = 2;"));
455 assert!(proto.contains("string cursor = 3;"));
456 }
457
458 #[test]
459 fn generate_proto_with_timestamp() {
460 let mut schema = IndexMap::new();
461 schema.insert(
462 "id".to_string(),
463 FieldSchema {
464 primary: true,
465 generated: true,
466 ..field(FieldType::Uuid)
467 },
468 );
469 schema.insert(
470 "created_at".to_string(),
471 FieldSchema {
472 generated: true,
473 ..field(FieldType::Timestamp)
474 },
475 );
476
477 let resource = ResourceDefinition {
478 resource: "events".to_string(),
479 version: 2,
480 db: None,
481 tenant_key: None,
482 schema,
483 endpoints: None,
484 relations: None,
485 indexes: None,
486 };
487
488 let proto = generate_proto(&resource);
489 assert!(proto.contains("import \"google/protobuf/timestamp.proto\";"));
490 assert!(proto.contains("google.protobuf.Timestamp created_at = 2;"));
491 assert!(proto.contains("package shaperail.v2.events;"));
492 }
493
494 #[test]
495 fn generate_all_protos_multiple() {
496 let make_schema = || {
497 let mut s = IndexMap::new();
498 s.insert(
499 "id".to_string(),
500 FieldSchema {
501 primary: true,
502 ..field(FieldType::Uuid)
503 },
504 );
505 s
506 };
507
508 let resources = vec![
509 ResourceDefinition {
510 resource: "users".to_string(),
511 version: 1,
512 db: None,
513 tenant_key: None,
514 schema: make_schema(),
515 endpoints: None,
516 relations: None,
517 indexes: None,
518 },
519 ResourceDefinition {
520 resource: "orders".to_string(),
521 version: 1,
522 db: None,
523 tenant_key: None,
524 schema: make_schema(),
525 endpoints: None,
526 relations: None,
527 indexes: None,
528 },
529 ];
530
531 let protos = generate_all_protos(&resources);
532 assert_eq!(protos.len(), 2);
533 assert_eq!(protos[0].0, "users.proto");
534 assert_eq!(protos[1].0, "orders.proto");
535 assert!(protos[0].1.contains("message User {"));
536 assert!(protos[1].1.contains("message Order {"));
537 }
538}