1use crate::definitions::EnumElement;
2use crate::docs::Data;
3use convert_case::{Case, Casing};
4use endpoint_libs::model::{EndpointErrorSchema, EnumVariant, Type};
5use eyre::bail;
6use itertools::Itertools;
7use std::collections::{BTreeSet, HashMap};
8use std::fs::File;
9use std::io::Write;
10use std::path::Path;
11use std::process::Command;
12
13pub trait ToRust {
14 fn to_rust_ref(&self, serde_with: bool) -> String;
15 fn to_rust_decl(&self, serde_with: bool, add_derives: bool) -> String;
16 fn add_derives(&self, input: String) -> String;
17}
18
19impl ToRust for Type {
20 fn to_rust_ref(&self, serde_with: bool) -> String {
21 match self {
22 Type::UInt32 => "u32".to_owned(),
23 Type::Int32 => "i32".to_owned(),
24 Type::Int64 => "i64".to_owned(),
25 Type::Float64 => "f64".to_owned(),
26 Type::TimeStampMs => "i64".to_owned(),
27 Type::Struct { name, .. } => name.clone(),
28 Type::StructRef(name) => name.clone(),
29 Type::Object => "serde_json::Value".to_owned(),
30 Type::StructTable { struct_ref } => format!("Vec<{struct_ref}>"),
32 Type::Vec(ele) => {
33 format!("Vec<{}>", ele.to_rust_ref(serde_with))
34 }
35 Type::Unit => "()".to_owned(),
36 Type::Optional(t) => {
37 format!("Option<{}>", t.to_rust_ref(serde_with))
38 }
39 Type::Boolean => "bool".to_owned(),
40 Type::String => "String".to_owned(),
41 Type::Bytea => "Vec<u8>".to_owned(),
42 Type::UUID => "Uuid".to_owned(),
43 Type::NanoId { len } => format!("Nanoid<{len}, Base62Alphabet>"),
44 Type::IpAddr => "IpAddr".to_owned(),
45 Type::Enum { name, .. } => format!("Enum{}", name.to_case(Case::Pascal),),
46 Type::EnumRef { name, prefixed_name } => {
47 if *prefixed_name {
48 format!("Enum{}", name.to_case(Case::Pascal),)
49 } else {
50 name.to_case(Case::Pascal)
51 }
52 }
53 Type::BlockchainDecimal => "Decimal".to_owned(),
54 Type::BlockchainAddress if serde_with => "Address".to_owned(),
55 Type::BlockchainTransactionHash if serde_with => "H256".to_owned(),
56 Type::BlockchainAddress => "BlockchainAddress".to_owned(),
57 Type::BlockchainTransactionHash => "BlockchainTransactionHash".to_owned(),
58 other => panic!(
63 "endpoint-gen does not know how to emit Rust for {other:?}; \
64 upgrade endpoint-gen to match your endpoint-libs version"
65 ),
66 }
67 }
68
69 fn to_rust_decl(&self, serde_with: bool, add_derives: bool) -> String {
70 let code_regex = regex::Regex::new(r"=\s*(\d+)").expect("Error building regex to extract endpoint code");
71
72 match self {
73 Type::Struct { name, fields } => {
74 let mut fields = fields.iter().map(|x| {
75 let opt = matches!(&x.ty, Type::Optional(_));
76 let serde_with_opt = match &x.ty {
77 Type::BlockchainDecimal => "rust_decimal::serde::str",
78 Type::BlockchainAddress if serde_with => "WithBlockchainAddress",
79 Type::BlockchainTransactionHash if serde_with => "WithBlockchainTransactionHash",
80 _ => "",
91 };
92 format!(
93 "{} {} pub {}: {}",
94 if opt { "#[serde(default)]" } else { "" },
95 if serde_with_opt.is_empty() {
96 "".to_string()
97 } else {
98 format!("#[serde(with = \"{serde_with_opt}\")]")
99 },
100 x.name,
101 x.ty.to_rust_ref(serde_with)
102 )
103 });
104 let input = format!("pub struct {} {{{}}}", name, fields.join(","));
105
106 if add_derives { self.add_derives(input) } else { input }
107 }
108 Type::Enum { name, variants: fields } => {
109 let mut fields = fields
110 .iter()
111 .map(|x| {
112 let variant_name = if x.name.chars().last().unwrap().is_lowercase() {
113 x.name.to_case(Case::Pascal)
114 } else {
115 x.name.clone()
116 };
117 if x.description.trim().is_empty() {
120 format!(
121 r#"
122 {} = {}
123"#,
124 variant_name, x.value
125 )
126 } else {
127 format!(
128 r#"
129 /// {}
130 {} = {}
131"#,
132 x.description, variant_name, x.value
133 )
134 }
135 })
136 .sorted_by(|a, b| {
137 let code_a = {
139 match code_regex.captures(a) {
140 Some(code) => code[1].parse::<u64>().unwrap_or_else(|err| {
141 eprintln!("Sorting error: {err}: Rust output may not be sorted correctly");
142 0
143 }),
144 None => {
145 eprintln!("Sorting error: Rust output may not be sorted correctly");
146 0
147 }
148 }
149 };
150
151 let code_b = {
152 match code_regex.captures(b) {
153 Some(code) => code[1].parse::<u64>().unwrap_or_else(|err| {
154 eprintln!("Sorting error: {err}: Rust output may not be sorted correctly");
155 0
156 }),
157 None => {
158 eprintln!("Sorting error: Rust output may not be sorted correctly");
159 0
160 }
161 }
162 };
163
164 code_a.cmp(&code_b)
165 });
166 let enum_content = format!(
167 r#"pub enum Enum{} {{{}}}"#,
168 name.to_case(Case::Pascal),
169 fields.join(",")
170 );
171
172 if add_derives {
173 self.add_derives(enum_content)
174 } else {
175 enum_content
176 }
177 }
178 x => x.to_rust_ref(serde_with),
179 }
180 }
181
182 fn add_derives(&self, input: String) -> String {
183 match self {
184 Self::Enum { .. } => Self::add_default_enum_derives(input),
185 Self::Struct { .. } => Self::add_default_struct_derives(input),
186 _ => input,
187 }
188 }
189}
190
191pub fn collect_rust_recursive_types(t: Type) -> Vec<Type> {
192 match t {
193 Type::Struct { ref fields, .. } => {
194 let mut v = vec![t.clone()];
195 for x in fields {
196 v.extend(collect_rust_recursive_types(x.ty.clone()));
197 }
198 v
199 }
200 Type::Vec(x) => collect_rust_recursive_types(*x),
207 Type::Optional(x) => collect_rust_recursive_types(*x),
208 _ => vec![],
209 }
210}
211
212fn endpoint_error_enum_name(endpoint_name: &str) -> String {
213 format!("{}Error", endpoint_name.to_case(Case::Pascal))
214}
215
216fn endpoint_error_variant_name(error: &EndpointErrorSchema) -> String {
217 error.name.to_case(Case::Pascal)
218}
219
220pub(crate) fn error_code_variant_name(name: &str) -> String {
221 name.to_case(Case::Pascal)
222}
223
224fn endpoint_error_code_expr(error: &EndpointErrorSchema) -> String {
225 format!("EnumErrorCode::{}", error_code_variant_name(error.code.variant()))
226}
227
228fn rust_string_literal(value: &str) -> String {
229 serde_json::to_string(value).expect("string serialization should not fail")
230}
231
232fn gen_endpoint_error_enum(
233 endpoint_name: &str,
234 errors: &[EndpointErrorSchema],
235 mut writer: impl Write,
236) -> eyre::Result<()> {
237 if errors.is_empty() {
238 return Ok(());
239 }
240
241 let enum_name = endpoint_error_enum_name(endpoint_name);
242 writeln!(
243 writer,
244 "#[derive(Serialize, Deserialize, Debug, Clone)]\npub enum {enum_name} {{"
245 )?;
246
247 for error in errors {
248 let variant_name = endpoint_error_variant_name(error);
249 if !error.message.is_empty() {
250 writeln!(writer, " /// {}", error.message)?;
251 }
252 if error.fields.is_empty() {
253 writeln!(writer, " {variant_name},")?;
254 } else {
255 let fields = error
256 .fields
257 .iter()
258 .map(|field| format!("{}: {}", field.name, field.ty.to_rust_ref(true)))
259 .join(", ");
260 writeln!(writer, " {variant_name} {{ {fields} }},")?;
261 }
262 }
263
264 writeln!(writer, "}}\n")?;
265 writeln!(writer, "impl From<{enum_name}> for CustomError {{")?;
266 writeln!(writer, " fn from(err: {enum_name}) -> Self {{")?;
267 writeln!(writer, " match err {{")?;
268
269 for error in errors {
270 let variant_name = endpoint_error_variant_name(error);
271 let code_expr = endpoint_error_code_expr(error);
272 let message = &error.message;
273 let kind = rust_string_literal(&variant_name);
274 if error.fields.is_empty() {
275 writeln!(
276 writer,
277 " {enum_name}::{variant_name} => CustomError::new({code_expr}).with_message({}).with_kind({kind}),",
278 rust_string_literal(message),
279 )?;
280 } else {
281 let field_names = error.fields.iter().map(|field| field.name.as_str()).join(", ");
282 let json_fields = error
283 .fields
284 .iter()
285 .map(|field| format!(r#""{}": {}"#, field.name.to_case(Case::Camel), field.name))
286 .join(", ");
287 writeln!(
288 writer,
289 " {enum_name}::{variant_name} {{ {field_names} }} => CustomError::new({code_expr}).with_message({}).with_kind({kind}).with_details(serde_json::json!({{ {json_fields} }})),",
290 rust_string_literal(message),
291 )?;
292 }
293 }
294
295 writeln!(writer, " }}")?;
296 writeln!(writer, " }}")?;
297 writeln!(writer, "}}\n")?;
298
299 Ok(())
300}
301
302pub fn gen_model_rs(data: &Data) -> eyre::Result<()> {
303 let db_filename = data.output_dir.join("model.rs");
304
305 if let Some(parent) = db_filename.parent() {
307 std::fs::create_dir_all(parent)?;
308 }
309
310 let worktable_imports = if data.enums.iter().any(|e| e.config.worktable_support)
311 || data.structs.iter().any(|s| s.config.worktable_support)
312 {
313 r#"use worktable::prelude::*;
314 use rkyv::Archive;
315 "#
316 } else {
317 ""
318 };
319
320 let json_schema_imports = if data.enums.iter().any(|e| e.config.json_schema_gen)
321 || data.structs.iter().any(|s| s.config.json_schema_gen)
322 {
323 r#"use schemars::{schema_for, JsonSchema};"#
324 } else {
325 ""
326 };
327
328 let mut model_file = File::create(&db_filename)?;
329 write!(
330 &mut model_file,
331 "use endpoint_libs::libs::error_code::ErrorCode;
332 use endpoint_libs::libs::ws::*;
333 use endpoint_libs::libs::types::*;
334 use endpoint_libs::libs::ws::toolbox::CustomError;
335 use num_derive::FromPrimitive;
336 use serde::*;
337 use strum_macros::{{Display, EnumString}};
338 use uuid::Uuid;
339 use psc_nanoid::{{Nanoid, alphabet::Base62Alphabet}};
340 use std::net::IpAddr;
341 {worktable_imports}
342 {json_schema_imports}
343 ",
344 )?;
345
346 for e in &data.enums {
347 writeln!(&mut model_file, "{}", e.to_rust_decl(false, true))?;
348 }
349 for s in &data.structs {
350 writeln!(&mut model_file, "{}", s.to_rust_decl(false, true))?;
351 }
352 check_endpoint_codes(data, &mut model_file)?;
353 dump_endpoint_schema(data, &mut model_file)?;
354 dump_type_registry(data, &mut model_file)?;
355
356 let enum_ = Type::enum_(
357 "ErrorCode",
358 data.error_codes
359 .iter()
360 .map(|x| EnumVariant::new_with_description(error_code_variant_name(&x.name), x.description.clone(), x.code))
361 .collect(),
362 );
363 writeln!(&mut model_file, "{}", enum_.to_rust_decl(false, true))?;
364 writeln!(
365 &mut model_file,
366 r#"
367impl From<EnumErrorCode> for ErrorCode {{
368 fn from(e: EnumErrorCode) -> Self {{
369 ErrorCode::new(e as _)
370 }}
371}}
372 "#
373 )?;
374
375 let mut endpoint_reqres_types = BTreeSet::new();
376 for s in &data.services {
377 for e in &s.endpoints {
378 let req = Type::struct_(format!("{}Request", e.schema.name), e.schema.parameters.clone());
379 let resp = Type::struct_(format!("{}Response", e.schema.name), e.schema.returns.clone());
380 endpoint_reqres_types.extend(
381 [
382 collect_rust_recursive_types(req),
383 collect_rust_recursive_types(resp),
384 e.schema
385 .stream_response
386 .clone()
387 .into_iter()
388 .flat_map(Type::try_unwrap)
389 .collect::<Vec<_>>(),
390 e.schema
391 .errors
392 .iter()
393 .flat_map(|error| {
394 error
395 .fields
396 .iter()
397 .flat_map(|field| collect_rust_recursive_types(field.ty.clone()))
398 })
399 .collect::<Vec<_>>(),
400 ]
401 .concat(),
402 );
403 }
404 }
405 for s in endpoint_reqres_types {
406 write!(&mut model_file, r#"{}"#, s.to_rust_decl(true, true))?;
407 }
408
409 for s in &data.services {
410 for endpoint in &s.endpoints {
411 gen_endpoint_error_enum(&endpoint.schema.name, &endpoint.schema.errors, &mut model_file)?;
412 }
413 }
414
415 for s in &data.services {
416 for endpoint in &s.endpoints {
417 let roles_list = resolve_roles_ids(&endpoint.schema.roles, &data.enums)
418 .into_iter()
419 .map(|x| x.to_string())
420 .join(", ");
421
422 write!(
423 &mut model_file,
424 "
425impl WsRequest for {end_name2}Request {{
426 type Response = {end_name2}Response;
427 const METHOD_ID: u32 = {code};
428 const ROLES: &[u32] = &[{roles_list}];
429 const SCHEMA: &'static str = r#\"{schema}\"#;
430}}
431impl WsResponse for {end_name2}Response {{
432 type Request = {end_name2}Request;
433}}
434",
435 end_name2 = endpoint.schema.name.to_case(Case::Pascal),
436 code = endpoint.schema.code,
437 schema = serde_json::to_string_pretty(&endpoint.schema).unwrap()
438 )?;
439 }
440 }
441 model_file.flush()?;
442 drop(model_file);
443 rustfmt(&db_filename)?;
444
445 Ok(())
446}
447
448fn resolve_roles_ids(endpoint_roles: &Vec<String>, all_enums: &Vec<EnumElement>) -> Vec<i64> {
451 let mut all_enums_typed: HashMap<String, Vec<EnumVariant>> = HashMap::new();
452 for e in all_enums {
453 if let Type::Enum { name: _, variants } = &e.inner {
454 all_enums_typed.insert(e.to_rust_ref(false), variants.clone());
455 }
456 }
457
458 let mut roles_ids = vec![];
459 for role in endpoint_roles {
460 let (role_enum_name, role_variant_name) = role.split_once("::").unwrap_or(("", role.as_str()));
461
462 if let Some(role_enum_variants) = all_enums_typed.get(role_enum_name) {
463 if let Some(role_variant_in_endpoint) = role_enum_variants.iter().find(|v| v.name == role_variant_name) {
464 roles_ids.push(role_variant_in_endpoint.value);
465 } else {
466 eprintln!("Warning: Role variant '{role_variant_name}' not found in enum '{role_enum_name}'");
467 }
468 } else {
469 eprintln!("Warning: Role enum '{role_enum_name}' not found");
470 }
471 }
472 let mut roles_ids_set: BTreeSet<i64> = BTreeSet::new();
474 for id in &roles_ids {
475 if !roles_ids_set.insert(*id) {
476 eprintln!("Warning: Duplicate role ID found: {id}");
477 }
478 }
479
480 roles_ids_set.into_iter().collect()
481}
482
483pub fn rustfmt(f: &Path) -> eyre::Result<()> {
484 let exit = Command::new("rustfmt")
485 .arg("--edition")
486 .arg("2021")
487 .arg(f)
488 .spawn()?
489 .wait()?;
490 if !exit.success() {
491 bail!("failed to rustfmt {:?}", exit);
492 }
493 Ok(())
494}
495
496pub fn check_endpoint_codes(data: &Data, mut writer: impl Write) -> eyre::Result<()> {
497 let mut variants = vec![];
498 for s in &data.services {
499 for e in &s.endpoints {
500 variants.push(EnumVariant::new(e.schema.name.clone(), e.schema.code as _));
501 }
502 }
503 let enum_ = Type::enum_("Endpoint", variants);
504 writeln!(writer, "{}", enum_.to_rust_decl(false, true))?;
505 Ok(())
507}
508pub fn dump_endpoint_schema(data: &Data, mut writer: impl Write) -> eyre::Result<()> {
509 let mut cases = vec![];
510 for s in &data.services {
511 for e in &s.endpoints {
512 cases.push(format!(
513 "Self::{name} => {name}Request::SCHEMA,",
514 name = e.schema.name.to_case(Case::Pascal),
515 ));
516 }
517 }
518 let code = format!(
519 r#"
520 impl EnumEndpoint {{
521 pub fn schema(&self) -> endpoint_libs::model::EndpointSchema {{
522 let schema = match self {{
523 {cases}
524 }};
525 serde_json::from_str(schema).unwrap()
526 }}
527 }}
528 "#,
529 cases = cases.join("\n")
530 );
531 writeln!(writer, "{code}")?;
532 Ok(())
533}
534
535pub fn shared_type_definitions(data: &Data) -> Vec<Type> {
538 let mut types: Vec<Type> = data.structs.iter().map(|s| s.inner.clone()).collect();
539 types.extend(data.enums.iter().map(|e| e.inner.clone()));
540 types.push(Type::enum_(
541 "ErrorCode",
542 data.error_codes
543 .iter()
544 .map(|x| EnumVariant::new_with_description(error_code_variant_name(&x.name), x.description.clone(), x.code))
545 .collect(),
546 ));
547 types
548}
549
550pub fn dump_type_registry(data: &Data, mut writer: impl Write) -> eyre::Result<()> {
555 let types = shared_type_definitions(data);
556 let serialized = serde_json::to_string_pretty(&types)?;
557 let code = format!(
558 r##"
559/// JSON-serialized shared struct/enum definitions referenced by endpoint schemas.
560pub const TYPE_DEFINITIONS: &'static str = r#"{serialized}"#;
561
562/// Builds the type registry over all shared definitions, for use with
563/// `WebsocketServer::enable_mcp()`.
564pub fn type_registry() -> endpoint_libs::model::TypeRegistry {{
565 let types: Vec<endpoint_libs::model::Type> =
566 serde_json::from_str(TYPE_DEFINITIONS).expect("Invalid embedded type definitions");
567 let mut registry = endpoint_libs::model::TypeRegistry::new();
568 registry.add_all(types.iter());
569 registry
570}}
571"##
572 );
573 writeln!(writer, "{code}")?;
574 Ok(())
575}
576
577#[cfg(test)]
578mod tests {
579 use regex::Regex;
580
581 use super::*;
582 use crate::definitions::{EndpointSchemaElement, RustGenConfig};
583 use endpoint_libs::model::{EndpointSchema, Field};
584
585 #[test]
586 fn enum_decl_omits_doc_comment_for_blank_descriptions() {
587 let e = Type::enum_(
588 "sample",
589 vec![
590 EnumVariant::new_with_description("Documented", "Has docs.", 0),
591 EnumVariant::new("Bare", 1),
592 EnumVariant::new_with_description("Blank", " ", 2),
593 ],
594 );
595 let decl = e.to_rust_decl(false, false);
596 assert!(decl.contains("/// Has docs."));
597 assert!(
598 !decl.lines().any(|l| l.trim() == "///"),
599 "empty doc comment emitted (clippy::empty_docs):\n{decl}"
600 );
601 assert!(decl.contains("Bare = 1"));
602 assert!(decl.contains("Blank = 2"));
603 }
604
605 fn test_data() -> Data {
606 let user_info = Type::struct_(
607 "UserInfo",
608 vec![
609 Field::new("user_id", Type::Int64),
610 Field::new("role", Type::enum_ref("role", true)),
611 ],
612 );
613 let role = Type::enum_("role", vec![EnumVariant::new("Admin", 1)]);
614 Data {
615 project_name: "api.example.com".into(),
616 project_root: std::path::PathBuf::new(),
617 output_dir: std::path::PathBuf::new(),
618 services: vec![crate::definitions::GenService::new(
619 "user".to_string(),
620 1,
621 vec![EndpointSchemaElement {
622 frontend_facing: true,
623 config: RustGenConfig::default(),
624 schema: EndpointSchema::new(
625 "UserGetProfile",
626 10010,
627 vec![Field::new("user_id", Type::Int64)],
628 vec![Field::new("profile", Type::struct_ref("UserInfo"))],
629 )
630 .with_description("Fetches a user profile."),
631 }],
632 )],
633 enums: vec![crate::definitions::EnumElement {
634 config: RustGenConfig::default(),
635 inner: role,
636 }],
637 structs: vec![crate::definitions::StructElement {
638 config: RustGenConfig::default(),
639 inner: user_info,
640 }],
641 error_codes: vec![],
642 }
643 }
644
645 #[test]
646 fn type_registry_dump_round_trips() {
647 let data = test_data();
648 let mut out = Vec::new();
649 dump_type_registry(&data, &mut out).unwrap();
650 let code = String::from_utf8(out).unwrap();
651
652 assert!(code.contains("pub const TYPE_DEFINITIONS"));
653 assert!(code.contains("pub fn type_registry()"));
654
655 let start = code.find("r#\"").unwrap() + 3;
658 let end = code.find("\"#").unwrap();
659 let types: Vec<Type> = serde_json::from_str(&code[start..end]).unwrap();
660 assert_eq!(types.len(), 3);
662
663 let mut registry = endpoint_libs::model::TypeRegistry::new();
664 registry.add_all(types.iter());
665 assert!(registry.get_struct("UserInfo").is_some());
666 assert!(registry.get_enum("role").is_some());
667 assert!(registry.get_enum("ErrorCode").is_some());
668
669 let schema = &data.services[0].endpoints[0].schema;
671 let input = schema.to_mcp_input_schema(®istry).unwrap();
672 assert_eq!(input["required"], serde_json::json!(["userId"]));
673 let output = schema.to_mcp_output_schema(®istry).unwrap();
674 assert!(output["$defs"]["UserInfo"].is_object());
675 }
676
677 #[test]
678 fn test_extract_number_from_error_code() {
679 let re = Regex::new(r"=\s*(\d+)").unwrap();
680
681 let text1 = r#" ///
683 LoginStep2 = 10003
684 ,"#;
685 let caps1 = re.captures(text1).expect("Should match");
686 let number1: u64 = caps1[1].parse().expect("Should parse as u64");
687 assert_eq!(number1, 10003);
688
689 let text2 = "Authorize = 10000,";
691 let caps2 = re.captures(text2).expect("Should match");
692 let number2: u64 = caps2[1].parse().expect("Should parse as u64");
693 assert_eq!(number2, 10000);
694
695 let text3 = "SomeError=12345,";
697 let caps3 = re.captures(text3).expect("Should match");
698 let number3: u64 = caps3[1].parse().expect("Should parse as u64");
699 assert_eq!(number3, 12345);
700
701 let text4 = r#"/// SQL R0019 UnauthorizedMessage
703 UnauthorizedMessage = 45349677
704, "#;
705 let caps4 = re.captures(text4).expect("Should match");
706 let number4: u64 = caps4[1].parse().expect("Should parse as u64");
707 assert_eq!(number4, 45349677);
708 }
709}