Skip to main content

shaperail_codegen/
proto.rs

1//! Proto file generation from resource definitions (M16).
2//!
3//! Generates `.proto` files from `ResourceDefinition`s. Each resource produces
4//! a service with CRUD RPCs including server-streaming for list endpoints.
5
6use shaperail_core::{FieldType, ResourceDefinition};
7
8/// Maps a Shaperail `FieldType` to a protobuf type string.
9fn 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
25/// Returns true if the field type requires a well-known-types import.
26pub fn needs_wkt_import(ft: &FieldType) -> bool {
27    matches!(
28        ft,
29        FieldType::Timestamp | FieldType::Json | FieldType::Array
30    )
31}
32
33/// Converts a snake_case resource name to PascalCase for protobuf message names.
34fn 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
49/// Converts a plural resource name to a singular form for message naming.
50/// Simple heuristic: strip trailing 's' if present.
51fn to_singular(s: &str) -> String {
52    // Words that end in 's' but are not plural
53    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        // Strip "es" from "addresses", "boxes", "buzzes" etc.
62        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
74/// Generates a `.proto` file content from a single `ResourceDefinition`.
75///
76/// The generated proto includes:
77/// - A message type for the resource with all schema fields
78/// - Create/Update input messages based on endpoint `input` fields
79/// - Request/Response wrappers for each endpoint
80/// - A gRPC service with RPCs for each declared endpoint
81/// - Server-streaming RPC for list endpoints
82pub 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    // Check if we need WKT imports
93    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    // Resource message
119    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    // Determine endpoints
134    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    // List request/response
142    if has_list {
143        proto.push_str(&format!("message List{pascal_plural}Request {{\n"));
144        // Add filter fields from the list endpoint
145        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    // Get request/response
174    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    // Create request/response
185    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    // Update request/response
207    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    // Delete request/response
230    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    // Service definition
241    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
294/// Generates proto files for all resources, returning `(filename, content)` pairs.
295pub 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}