Skip to main content

endpoint_gen/
docs.rs

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    /// Human-facing name of the project, used as the title of the emitted
15    /// specification documents.
16    ///
17    /// Deliberately separate from `project_root`: `--check` regenerates into a
18    /// scratch directory, and deriving the title from the output path would
19    /// make every document differ from its committed copy on every check.
20    /// Document identity is data, not an artefact of where it was written.
21    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    // Ensure the parent directories exist
34    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    // Only write FE facing endpoints to the services.json file
42    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
105/// Wraps ` ` around the given string
106fn 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        // Type::DataTable { name, fields } => {
164        //     format!(
165        //         "{}: Vec<{}{:#}>",
166        //         field_name.to_case(Case::Camel),
167        //         name.to_case(Case::Pascal),
168        //         format!(
169        //             "{{ {} }}",
170        //             fields
171        //                 .iter()
172        //                 .map(|x| format!(
173        //                     "{}: {}",
174        //                     x.name.to_case(Case::Camel),
175        //                     x.ty.to_rust_ref(false)
176        //                 ))
177        //                 .join(", ")
178        //         )
179        //     )
180        // }
181        _ => 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
279/// Writes `docs/<service>_mcp_tools.json` for each service: the MCP tool list
280/// (name, description, inputSchema, outputSchema) exactly as a server built
281/// from these schemas will report it via `tools/list`. Intended for review —
282/// schema changes show up as diffs in these files.
283pub 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(&registry).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(&registry)
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
367/// Writes `docs/openapi-README.md`: what the two specification documents are,
368/// the synthetic-path warning, and how to consume them.
369///
370/// Generated rather than hand-written so it cannot drift out of sync with the
371/// emitters, and so every consumer repo gets it without copying a file around.
372pub 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    // The table lists only what was actually emitted — the specs are opt-in, so
380    // a README promising an openapi.json that does not exist would be a lie.
381    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}