1use crate::definitions::{EnumElement, ErrorCodeSchema, GenService, StructElement};
2use crate::rust::ToRust;
3use crate::service::get_systemd_service;
4use convert_case::{Case, Casing};
5use endpoint_libs::model::{EndpointSchema, Service, Type};
6use eyre::Context;
7use itertools::Itertools;
8use serde_json::json;
9use std::fs::{File, create_dir_all};
10use std::io::Write;
11use std::path::{Path, PathBuf};
12
13pub struct Data {
14 pub project_name: String,
22 pub project_root: PathBuf,
23 pub output_dir: PathBuf,
24 pub services: Vec<GenService>,
25 pub enums: Vec<EnumElement>,
26 pub structs: Vec<StructElement>,
27 pub error_codes: Vec<ErrorCodeSchema>,
28}
29
30pub fn gen_services_docs(docs: &Data) -> eyre::Result<()> {
31 let docs_filename = docs.project_root.join("docs").join("services.json");
32
33 if let Some(parent) = docs_filename.parent() {
35 std::fs::create_dir_all(parent)?;
36 }
37
38 let mut docs_file = File::create(&docs_filename)
39 .with_context(|| format!("Failed to create docs file: {}", docs_filename.display()))?;
40
41 let services = docs
43 .services
44 .clone()
45 .into_iter()
46 .map(|service| {
47 let fe_endpoints: Vec<EndpointSchema> = service
48 .endpoints
49 .into_iter()
50 .filter(|endpoint| endpoint.frontend_facing)
51 .collect();
52
53 Service::new(service.name, service.id, fe_endpoints)
54 })
55 .filter(|service| !service.endpoints.is_empty())
56 .collect::<Vec<Service>>();
57
58 let enums = doc_enums(docs);
59
60 let structs: Vec<Type> = docs
61 .structs
62 .clone()
63 .into_iter()
64 .map(|struct_element| struct_element.inner)
65 .collect();
66
67 serde_json::to_writer_pretty(
68 &mut docs_file,
69 &json!({
70 "services": services,
71 "enums": enums,
72 "structs": structs,
73 }),
74 )?;
75 Ok(())
76}
77
78fn error_code_enum(codes: &[ErrorCodeSchema]) -> Type {
79 Type::enum_(
80 "ErrorCode",
81 codes
82 .iter()
83 .map(|code| {
84 endpoint_libs::model::EnumVariant::new_with_description(
85 code.name.to_case(Case::Pascal),
86 code.description.clone(),
87 code.code,
88 )
89 })
90 .collect(),
91 )
92}
93
94fn doc_enums(data: &Data) -> Vec<Type> {
95 let mut enums: Vec<Type> = data
96 .enums
97 .clone()
98 .into_iter()
99 .map(|enum_element| enum_element.inner)
100 .collect();
101 enums.push(error_code_enum(&data.error_codes));
102 enums
103}
104
105fn wrap_code_md(value: String) -> String {
107 format!(r#"`{value}`"#)
108}
109
110fn format_type(field_name: &str, ty: &Type, datamodels: bool) -> String {
111 match ty {
112 Type::Struct { name, fields } => {
113 if !datamodels {
114 format!(
115 r#"{}: {}{:#}"#,
116 field_name.to_case(Case::Camel),
117 name.to_case(Case::Pascal),
118 format!(
119 "{{ {} }}",
120 fields
121 .iter()
122 .map(|x| format!("{}: {}", x.name, x.ty.to_rust_ref(false)))
123 .join(", ")
124 )
125 )
126 } else {
127 format!(
128 r#"{}{:#}"#,
129 name.to_case(Case::Pascal),
130 format!(
131 "{{ {} }}",
132 fields
133 .iter()
134 .map(|x| format!("{}: {}", x.name, x.ty.to_rust_ref(false)))
135 .join(", ")
136 )
137 )
138 }
139 }
140 Type::StructTable { struct_ref } => {
141 format!(
142 "{}: Vec<{}>",
143 field_name.to_case(Case::Camel),
144 struct_ref.to_case(Case::Pascal),
145 )
146 }
147 Type::Enum { name, variants } => {
148 format!(
149 "{} {{ {} }}",
150 name.to_case(Case::Pascal),
151 variants.iter().map(|v| &v.name).join(", ")
152 )
153 }
154 Type::EnumRef { name, prefixed_name } => {
155 format!(
156 "{}: {}",
157 field_name.to_case(Case::Camel),
158 prefixed_name
159 .then(|| format!("Enum{}", name.to_case(Case::Pascal)))
160 .unwrap_or(name.to_case(Case::Pascal))
161 )
162 }
163 _ => format!("{}: {}", field_name.to_case(Case::Camel), ty.to_rust_ref(false)),
182 }
183}
184
185fn format_errors(errors: &[endpoint_libs::model::EndpointErrorSchema]) -> String {
186 errors
187 .iter()
188 .map(|error| {
189 let fields = if error.fields.is_empty() {
190 String::new()
191 } else {
192 format!(
193 " {{{}}}",
194 error
195 .fields
196 .iter()
197 .map(|field| format!("{}: {}", field.name.to_case(Case::Camel), field.ty.to_rust_ref(false)))
198 .join(", ")
199 )
200 };
201 format!("{}({}){}", error.name, error.code, fields)
202 })
203 .join(", ")
204}
205
206pub fn gen_md_docs(data: &Data) -> eyre::Result<()> {
207 let docs_filename = data.project_root.join("docs").join("README.md");
208 let mut docs_file = File::create(docs_filename)?;
209 writeln!(
210 &mut docs_file,
211 r#"
212# API Reference
213
214## Structs/Datamodels
215
216```rust
217{}
218```
219---
220
221## Enums
222
223```rust
224{}
225```
226---
227
228 "#,
229 data.structs
230 .iter()
231 .map(|s| format!(
232 "struct {:#}\n",
233 format_type(&s.inner.to_rust_ref(false), &s.inner, true)
234 ))
235 .join("\n\n"),
236 data.enums
237 .iter()
238 .map(|e| e.inner.clone())
239 .chain(std::iter::once(error_code_enum(&data.error_codes)))
240 .map(|e| format!("enum {:#}\n", format_type(&e.to_rust_ref(false), &e, true)))
241 .join("\n\n")
242 )?;
243 for s in &data.services {
244 writeln!(
245 &mut docs_file,
246 r#"
247## {} Server
248ID: {}
249### Endpoints
250|Code|Name|Parameters|Response|Description|FE Facing|Errors|
251|-----------|-----------|----------|--------|-----------|-----------|-----------|"#,
252 s.name, s.id
253 )?;
254 for e in &s.endpoints {
255 writeln!(
256 &mut docs_file,
257 "|{}|{}|{}|{}|{}|{}|{}|",
258 e.schema.code,
259 e.schema.name,
260 e.schema
261 .parameters
262 .iter()
263 .map(|x| wrap_code_md(format_type(&x.name, &x.ty, false)))
264 .join(", "),
265 e.schema
266 .returns
267 .iter()
268 .map(|x| wrap_code_md(format_type(&x.name, &x.ty, false)))
269 .join(", "),
270 e.schema.description,
271 e.frontend_facing,
272 format_errors(&e.schema.errors),
273 )?;
274 }
275 }
276 Ok(())
277}
278
279pub fn gen_mcp_tools_json(data: &Data) -> eyre::Result<()> {
284 let docs_dir = data.project_root.join("docs");
285 create_dir_all(&docs_dir)?;
286
287 let mut registry = endpoint_libs::model::TypeRegistry::new();
288 registry.add_all(crate::rust::shared_type_definitions(data).iter());
289 for service in &data.services {
290 for endpoint in &service.endpoints {
291 registry.add_endpoint(&endpoint.schema);
292 }
293 }
294
295 for service in &data.services {
296 let tools = service
297 .endpoints
298 .iter()
299 .map(|endpoint| {
300 let schema = &endpoint.schema;
301 let mut tool = json!({
302 "name": schema.tool_name(),
303 "code": schema.code,
304 "description": schema.description,
305 "frontendFacing": endpoint.frontend_facing,
306 "inputSchema": schema.to_mcp_input_schema(®istry).with_context(|| {
307 format!("endpoint {} ({})", schema.name, schema.code)
308 })?,
309 });
310 if !schema.returns.is_empty() {
311 tool["outputSchema"] = schema
312 .to_mcp_output_schema(®istry)
313 .with_context(|| format!("endpoint {} ({})", schema.name, schema.code))?;
314 }
315 if schema.stream_response.is_some() {
316 tool["streaming"] = json!(true);
317 }
318 Ok(tool)
319 })
320 .collect::<eyre::Result<Vec<_>>>()?;
321
322 let filename = docs_dir.join(format!("{}_mcp_tools.json", service.name));
323 let file = File::create(&filename)
324 .with_context(|| format!("Failed to create MCP tools file: {}", filename.display()))?;
325 serde_json::to_writer_pretty(file, &json!({ "tools": tools }))?;
326 }
327 Ok(())
328}
329
330pub fn gen_systemd_services(data: &Data, app_name: &str, user: &str) -> eyre::Result<()> {
331 create_dir_all(data.project_root.join("etc").join("systemd"))?;
332
333 for srv in &data.services {
334 let service_filename = data
335 .project_root
336 .join("etc")
337 .join("systemd")
338 .join(format!("{}_{}.service", app_name, srv.name));
339 let mut service_file = File::create(&service_filename)?;
340 let v = get_systemd_service(app_name, &srv.name, user);
341 write!(&mut service_file, "{v}")?;
342 }
343 Ok(())
344}
345
346pub fn gen_error_message_md(root: &Path, codes: &[ErrorCodeSchema]) -> eyre::Result<()> {
347 let doc_filename = root.join("docs").join("error_codes").join("error_codes.md");
348
349 if let Some(parent) = doc_filename.parent() {
350 std::fs::create_dir_all(parent)?;
351 }
352
353 let mut doc_file = File::create(doc_filename)?;
354 writeln!(
355 &mut doc_file,
356 r#"
357# Error Messages
358|Error Code|Error Name|Description|
359|----------|----------|-----------|"#,
360 )?;
361 for item in codes {
362 writeln!(&mut doc_file, "|{}|{}|{}|", item.code, item.name, item.description)?;
363 }
364 Ok(())
365}
366
367pub fn gen_spec_readme(project_root: &Path, openapi: bool, asyncapi: bool) -> eyre::Result<()> {
373 let docs_dir = project_root.join("docs");
374 create_dir_all(&docs_dir)?;
375 let filename = docs_dir.join("openapi-README.md");
376 let mut file =
377 File::create(&filename).with_context(|| format!("Failed to create spec README: {}", filename.display()))?;
378
379 write!(&mut file, "{SPEC_README_HEAD}")?;
382 if asyncapi {
383 writeln!(
384 &mut file,
385 "| `asyncapi.json` | AsyncAPI 3.0. **The authoritative description of the wire protocol.** |"
386 )?;
387 }
388 if openapi {
389 writeln!(
390 &mut file,
391 "| `openapi.json` | OpenAPI 3.1. A projection for HTTP tooling. Not servable — see below. |"
392 )?;
393 }
394 writeln!(
395 &mut file,
396 "| `<service>_mcp_tools.json` | The MCP tool list a server reports via `tools/list`. |"
397 )?;
398 writeln!(
399 &mut file,
400 "| `services.json`, `README.md` | Human-facing dumps of the same model. |"
401 )?;
402
403 if openapi {
404 write!(&mut file, "{SPEC_README_OPENAPI}")?;
405 }
406 write!(&mut file, "{SPEC_README_TAIL}")?;
407 Ok(())
408}
409
410const SPEC_README_HEAD: &str = r#"# API specification documents
411
412Generated by `endpoint-gen` from the RON endpoint definitions in `config/`.
413**Do not edit these by hand** — regenerate with `endpoint-gen`, and prove they
414are current with `endpoint-gen --check` (which exits non-zero on drift).
415
416The specification documents are opt-in: pass `--openapi` and/or `--asyncapi`.
417Upgrading the generator does not add them on its own.
418
419| File | What it is |
420|---|---|
421"#;
422
423const SPEC_README_OPENAPI: &str = r#"
424## The OpenAPI document does not describe a servable HTTP API
425
426There are no URLs in this system. The transport is a persistent WebSocket
427carrying `{method, seq, params}` frames, where `method` is an endpoint code, plus
428MCP JSON-RPC 2.0 on the same socket.
429
430OpenAPI needs paths, so they are synthesized:
431
432```
433POST /{serviceName}/{endpoint_snake_name} e.g. POST /adminApi/delete_app
434 operationId: {serviceName}_{endpoint_snake_name}
435 tags: [{serviceName}]
436```
437
438The service segment is not decoration. Endpoint names are only conventionally
439unique — the RON namespace is `(service_name, service_id)` — so a flat
440`/rpc/{Name}` scheme would collide the moment two services reuse a name.
441
442**Generating an HTTP client from `openapi.json` and pointing it at the server
443will not work.** Use `asyncapi.json` to implement a real client.
444
445
446## Consuming these
447
448**Third-party SDKs.** Run `openapi-generator` against a `--public-only`
449document. Expect the synthetic paths to be wrong for real use — validate that it
450*generates*, not that it *connects*.
451
452```bash
453endpoint-gen --public-only
454```
455
456**OpenAPI → MCP bridging.** Point a bridge such as `rmcp-openapi` at
457`openapi.json` and compare its tool list against the `*_mcp_tools.json` files.
458A mismatch means the hand-rolled MCP metadata and the emitted spec disagree, and
459one of them is lying to an agent.
460
461**Spec-driven fuzzing.** Schemathesis wants a real HTTP surface, which this
462system does not have. It stays future work behind a REST adapter that does not
463exist yet.
464"#;
465
466const SPEC_README_TAIL: &str = r#"
467## Vendor extensions
468
469| Extension | Meaning |
470|---|---|
471| `x-endpoint-code` | The wire `method` code. |
472| `x-roles` | RBAC roles required. Deliberately *not* OAuth2 scopes — encoding them as scopes makes generators emit auth flows that do not exist. |
473| `x-frontend-facing` | Whether the endpoint is public. Drives `--public-only`. |
474| `x-stream-response` | The server may send multiple responses for one request. |
475| `x-error-codes` | Error codes the operation may return, resolved against the global catalog. |
476| `x-method-map` | AsyncAPI only: endpoint code → request message name. |
477| `x-framing` | AsyncAPI only: byte layout of the non-WebSocket local transport. |
478
479## Errors
480
481The protocol has no status codes, so no per-error HTTP statuses are invented.
482Every operation has a `default` response over the shared `ErrorEnvelope` schema
483(`code`, `message`, `params`), and `x-error-codes` lists what that operation can
484actually return.
485"#;
486
487#[cfg(test)]
488mod tests {
489 use super::*;
490 use crate::definitions::{EndpointSchemaElement, RustGenConfig};
491 use endpoint_libs::model::Field;
492
493 #[test]
494 fn mcp_tools_json_is_written_per_service() {
495 let dir = std::env::temp_dir().join(format!("endpointgen-mcp-test-{}", std::process::id()));
496 std::fs::create_dir_all(&dir).unwrap();
497
498 let data = Data {
499 project_name: "test".into(),
500 project_root: dir.clone(),
501 output_dir: dir.clone(),
502 services: vec![GenService::new(
503 "user".to_string(),
504 1,
505 vec![EndpointSchemaElement {
506 frontend_facing: true,
507 config: RustGenConfig::default(),
508 schema: EndpointSchema::new(
509 "UserGetProfile",
510 10010,
511 vec![Field::new("user_id", Type::Int64)],
512 vec![Field::new("ok", Type::Boolean)],
513 )
514 .with_description("Fetches a user profile."),
515 }],
516 )],
517 enums: vec![],
518 structs: vec![],
519 error_codes: vec![],
520 };
521
522 gen_mcp_tools_json(&data).unwrap();
523
524 let out = std::fs::read_to_string(dir.join("docs").join("user_mcp_tools.json")).unwrap();
525 let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
526 let tool = &parsed["tools"][0];
527 assert_eq!(tool["name"], json!("user_get_profile"));
528 assert_eq!(tool["code"], json!(10010));
529 assert_eq!(tool["inputSchema"]["required"], json!(["userId"]));
530 assert_eq!(tool["outputSchema"]["properties"]["ok"]["type"], json!("boolean"));
531
532 std::fs::remove_dir_all(&dir).ok();
533 }
534}