Skip to main content

endpoint_gen/
openapi.rs

1//! OpenAPI 3.1 emission.
2//!
3//! **This document is a projection for tooling, not a description of a running
4//! HTTP surface.** The server speaks WebSocket, carrying `{method, seq, params}`
5//! frames; there are no URLs. The paths here are synthesized so that OpenAPI
6//! tooling — client generators, doc renderers, OpenAPI→MCP bridges — has
7//! something to chew on. Generating an HTTP client from this and pointing it at
8//! the server will not work. The AsyncAPI document (see [`crate::asyncapi`]) is
9//! the authoritative description of the wire protocol.
10//!
11//! That warning is repeated in the emitted `info.description` and in
12//! `docs/openapi-README.md`, because an undocumented synthetic path map is worse
13//! than no document at all: it looks usable.
14//!
15//! Conventions (PLAN-2.1 §3.1), which mirror the RON namespace `(service_name,
16//! service_id)`:
17//!
18//! ```text
19//! POST /{serviceName}/{endpoint_snake_name}
20//!   operationId: {serviceName}_{endpoint_snake_name}
21//!   tags:        [{serviceName}]
22//! ```
23
24use convert_case::{Case, Casing};
25use endpoint_libs::model::{EndpointSchema, SchemaComponents, TypeRegistry, apply_meta};
26use eyre::{Context, Result};
27use serde_json::{Value, json};
28
29use crate::definitions::{ErrorCodeSchema, GenService};
30use crate::docs::Data;
31use crate::spec_common::{
32    ERROR_ENVELOPE, build_registry, collect_components, document_schemas, document_title, error_code_list,
33    visible_services,
34};
35
36/// The security scheme name used for every operation.
37const SESSION_TOKEN_SCHEME: &str = "sessionToken";
38
39/// Builds the document. Separated from writing so tests can assert on the value
40/// without touching a filesystem.
41pub fn build_openapi(data: &Data, public_only: bool) -> Result<Value> {
42    let registry = build_registry(data);
43    let services = visible_services(data, public_only);
44
45    let components = collect_components(&services, &registry)?;
46
47    let mut paths = serde_json::Map::new();
48    let mut tags = Vec::new();
49
50    for service in &services {
51        tags.push(json!({
52            "name": service.name,
53            "description": format!("Endpoints of the `{}` service (service id {}).", service.name, service.id),
54        }));
55
56        for element in &service.endpoints {
57            let schema = &element.schema;
58            let path = operation_path(&service.name, &schema.name);
59            let operation = build_operation(
60                service,
61                schema,
62                element.frontend_facing,
63                &components,
64                &registry,
65                &data.error_codes,
66            )
67            .with_context(|| format!("endpoint {} ({})", schema.name, schema.code))?;
68
69            paths.insert(path, json!({ "post": operation }));
70        }
71    }
72
73    Ok(json!({
74        "openapi": "3.1.0",
75        "info": {
76            "title": document_title(data),
77            "version": "1.0.0",
78            "description": INFO_DESCRIPTION,
79        },
80        // A relative "/" rather than a plausible https:// URL. OpenAPI defaults
81        // to this when `servers` is omitted, but linters flag an absent
82        // `servers` as an error, and a real-looking host would invite exactly
83        // the mistake info.description warns about.
84        "servers": [{
85            "url": "/",
86            "description": "Not a real server. This document is a projection for tooling; \
87                            the transport is a WebSocket. See info.description.",
88        }],
89        "tags": tags,
90        "paths": Value::Object(paths),
91        "components": {
92            "schemas": document_schemas(&components),
93            "securitySchemes": {
94                SESSION_TOKEN_SCHEME: {
95                    "type": "apiKey",
96                    "in": "header",
97                    "name": "Sec-WebSocket-Protocol",
98                    "description": "Auth token passed as a WebSocket subprotocol; see AuthController.",
99                }
100            },
101        },
102    }))
103}
104
105/// Writes `docs/openapi.json`.
106pub fn gen_openapi(data: &Data, public_only: bool) -> Result<()> {
107    let docs_dir = data.project_root.join("docs");
108    std::fs::create_dir_all(&docs_dir)?;
109
110    let document = build_openapi(data, public_only)?;
111    let filename = docs_dir.join("openapi.json");
112    let file = std::fs::File::create(&filename)
113        .with_context(|| format!("Failed to create OpenAPI file: {}", filename.display()))?;
114    serde_json::to_writer_pretty(file, &document)?;
115    Ok(())
116}
117
118/// The synthetic-path warning. Non-negotiable — see the module docs.
119const INFO_DESCRIPTION: &str = "\
120PROJECTION FOR TOOLING — NOT A SERVABLE HTTP API.
121
122This document is generated from declarative RON endpoint definitions
123(endpoint-libs / endpoint-gen). The production transport is a persistent
124WebSocket carrying {method, seq, params} frames, plus MCP JSON-RPC 2.0 on the
125same socket. There are no HTTP paths; the ones below are synthesized as
126/{serviceName}/{endpoint_snake_name} so that OpenAPI tooling has a stable
127handle on each operation.
128
129Generating an HTTP client from this document and pointing it at the server
130will NOT work. For an authoritative description of the wire protocol, use the
131AsyncAPI document (docs/asyncapi.json).
132
133Vendor extensions: x-endpoint-code (the wire method code), x-roles (RBAC roles
134required), x-frontend-facing, x-stream-response, x-error-codes.";
135
136/// `POST /{serviceName}/{endpoint_snake_name}`.
137///
138/// Endpoint names are only conventionally unique across services — the RON
139/// namespace is `(service_name, service_id)` — so the path must carry the
140/// service. A flat `/rpc/{Name}` scheme collides the moment two services reuse
141/// a name, and `/rpc/` carries no information.
142fn operation_path(service_name: &str, endpoint_name: &str) -> String {
143    format!("/{}/{}", service_name, endpoint_name.to_case(Case::Snake))
144}
145
146fn operation_id(service_name: &str, endpoint_name: &str) -> String {
147    format!("{}_{}", service_name, endpoint_name.to_case(Case::Snake))
148}
149
150#[allow(clippy::too_many_arguments)]
151fn build_operation(
152    service: &GenService,
153    schema: &EndpointSchema,
154    frontend_facing: bool,
155    components: &SchemaComponents,
156    registry: &TypeRegistry,
157    error_codes: &[ErrorCodeSchema],
158) -> Result<Value> {
159    let mut operation = serde_json::Map::new();
160
161    operation.insert("operationId".into(), json!(operation_id(&service.name, &schema.name)));
162    operation.insert("tags".into(), json!([service.name]));
163
164    // `summary` is the first line, `description` the whole thing. Renderers show
165    // summary in the operation list and description on the detail page.
166    if !schema.description.is_empty() {
167        let summary = schema.description.lines().next().unwrap_or_default().trim();
168        if !summary.is_empty() {
169            operation.insert("summary".into(), json!(summary));
170        }
171        operation.insert("description".into(), json!(schema.description));
172    }
173
174    operation.insert("x-endpoint-code".into(), json!(schema.code));
175    operation.insert("x-frontend-facing".into(), json!(frontend_facing));
176    if !schema.roles.is_empty() {
177        operation.insert("x-roles".into(), json!(schema.roles));
178    }
179
180    if schema.stream_response.is_some() {
181        operation.insert("x-stream-response".into(), json!(true));
182        let note = "This endpoint streams: the server may send multiple response \
183                    frames for one request. That has no HTTP equivalent and is not \
184                    represented in the responses below — see the AsyncAPI document.";
185        let described = match operation.get("description") {
186            Some(Value::String(existing)) => format!("{existing}\n\n{note}"),
187            _ => note.to_string(),
188        };
189        operation.insert("description".into(), json!(described));
190    }
191
192    operation.insert(
193        "security".into(),
194        json!([{ SESSION_TOKEN_SCHEME: Vec::<String>::new() }]),
195    );
196
197    if !schema.parameters.is_empty() {
198        let request = components.request_schema(schema, registry)?;
199        operation.insert(
200            "requestBody".into(),
201            json!({
202                "required": true,
203                "content": { "application/json": { "schema": request } },
204            }),
205        );
206    }
207
208    operation.insert(
209        "responses".into(),
210        build_responses(schema, components, registry, error_codes)?,
211    );
212
213    let mut operation = Value::Object(operation);
214    apply_meta(&mut operation, &schema.meta, &format!("endpoint {}", schema.name))?;
215    Ok(operation)
216}
217
218/// `200` plus a `default` error response.
219///
220/// The wire protocol has no status codes, so inventing per-error HTTP statuses
221/// would be fiction. The envelope is the contract; `x-error-codes` lists what
222/// this endpoint may return, resolved against the global catalog.
223fn build_responses(
224    schema: &EndpointSchema,
225    components: &SchemaComponents,
226    registry: &TypeRegistry,
227    error_codes: &[ErrorCodeSchema],
228) -> Result<Value> {
229    let response_schema = components.response_schema(schema, registry)?;
230
231    let mut error_response = json!({
232        "description": "Error envelope. The wire protocol has no status codes; \
233                        failures arrive as this object.",
234        "content": {
235            "application/json": {
236                "schema": { "$ref": format!("#/components/schemas/{ERROR_ENVELOPE}") }
237            }
238        },
239    });
240
241    if let Some(listed) = error_code_list(schema, error_codes) {
242        error_response["x-error-codes"] = listed;
243    }
244
245    Ok(json!({
246        "200": {
247            "description": "Success.",
248            "content": { "application/json": { "schema": response_schema } },
249        },
250        "default": error_response,
251    }))
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use crate::definitions::{EndpointSchemaElement, RustGenConfig};
258    use endpoint_libs::model::{EndpointErrorCodeRef, EndpointErrorSchema, Field, Type, collect_refs};
259    use std::path::PathBuf;
260
261    fn element(schema: EndpointSchema, frontend_facing: bool) -> EndpointSchemaElement {
262        EndpointSchemaElement {
263            frontend_facing,
264            config: RustGenConfig::default(),
265            schema,
266        }
267    }
268
269    fn data_with(services: Vec<GenService>) -> Data {
270        Data {
271            project_name: "api.example.com".into(),
272            project_root: PathBuf::from("/tmp/api.example.com"),
273            output_dir: PathBuf::from("/tmp/api.example.com/generated"),
274            services,
275            enums: vec![],
276            structs: vec![],
277            error_codes: vec![ErrorCodeSchema::new("BadRequest", 400, "The request was malformed.")],
278        }
279    }
280
281    fn sample_data() -> Data {
282        let login = EndpointSchema::new(
283            "UserLogin",
284            10000,
285            vec![
286                Field::new("user_name", Type::String),
287                Field::new("cursor", Type::Optional(Box::new(Type::String))),
288            ],
289            vec![Field::new("access_token", Type::String)],
290        )
291        .with_description("Logs a user in.\nReturns a session token.")
292        .with_errors(vec![
293            EndpointErrorSchema::new("PasswordTooShort", EndpointErrorCodeRef::new("BadRequest"))
294                .with_message("Password too short"),
295        ]);
296
297        let internal = EndpointSchema::new("AdminPurge", 40000, vec![], vec![]).with_description("Purges everything.");
298
299        data_with(vec![GenService::new(
300            "userApi".into(),
301            1,
302            vec![element(login, true), element(internal, false)],
303        )])
304    }
305
306    #[test]
307    fn paths_and_operation_ids_follow_the_service_convention() {
308        let doc = build_openapi(&sample_data(), false).unwrap();
309        let op = &doc["paths"]["/userApi/user_login"]["post"];
310
311        assert_eq!(op["operationId"], "userApi_user_login");
312        assert_eq!(op["tags"], json!(["userApi"]));
313        assert_eq!(op["x-endpoint-code"], 10000);
314    }
315
316    #[test]
317    fn summary_is_the_first_line_description_is_the_whole_thing() {
318        let doc = build_openapi(&sample_data(), false).unwrap();
319        let op = &doc["paths"]["/userApi/user_login"]["post"];
320
321        assert_eq!(op["summary"], "Logs a user in.");
322        assert_eq!(op["description"], "Logs a user in.\nReturns a session token.");
323    }
324
325    #[test]
326    fn optional_parameters_are_not_required() {
327        let doc = build_openapi(&sample_data(), false).unwrap();
328        let schema =
329            &doc["paths"]["/userApi/user_login"]["post"]["requestBody"]["content"]["application/json"]["schema"];
330        assert_eq!(schema["required"], json!(["userName"]));
331    }
332
333    #[test]
334    fn errors_become_a_default_response_not_invented_statuses() {
335        let doc = build_openapi(&sample_data(), false).unwrap();
336        let responses = &doc["paths"]["/userApi/user_login"]["post"]["responses"];
337
338        assert!(responses["200"].is_object());
339        assert!(responses["default"].is_object());
340        assert!(
341            responses.get("400").is_none(),
342            "must not invent per-error HTTP statuses"
343        );
344
345        let listed = &responses["default"]["x-error-codes"][0];
346        assert_eq!(listed["name"], "PasswordTooShort");
347        assert_eq!(listed["code"], "BadRequest");
348        // Resolved against the global catalog.
349        assert_eq!(listed["value"], 400);
350        assert_eq!(listed["description"], "The request was malformed.");
351    }
352
353    #[test]
354    fn public_only_drops_exactly_the_non_frontend_facing_operations() {
355        let full = build_openapi(&sample_data(), false).unwrap();
356        assert!(full["paths"]["/userApi/admin_purge"].is_object());
357
358        let public = build_openapi(&sample_data(), true).unwrap();
359        assert!(public["paths"]["/userApi/user_login"].is_object());
360        assert!(
361            public["paths"].get("/userApi/admin_purge").is_none(),
362            "public-only document must drop internal operations"
363        );
364    }
365
366    #[test]
367    fn every_ref_resolves_and_no_defs_survive() {
368        let registry_struct = Type::struct_(
369            "Wallet",
370            vec![
371                Field::new("id", Type::Int64),
372                Field::new("owner", Type::StructRef("Wallet".into())),
373            ],
374        );
375        let mut data = data_with(vec![GenService::new(
376            "walletApi".into(),
377            2,
378            vec![element(
379                EndpointSchema::new(
380                    "GetWallet",
381                    20000,
382                    vec![],
383                    vec![Field::new("wallet", Type::StructRef("Wallet".into()))],
384                )
385                .with_description("Gets a wallet."),
386                true,
387            )],
388        )]);
389        data.structs = vec![crate::definitions::StructElement {
390            config: RustGenConfig::default(),
391            inner: registry_struct,
392        }];
393
394        let doc = build_openapi(&data, false).unwrap();
395
396        let mut refs = vec![];
397        collect_refs(&doc, &mut refs);
398        assert!(!refs.is_empty());
399
400        let schemas = doc["components"]["schemas"].as_object().unwrap();
401        for target in &refs {
402            let name = target
403                .strip_prefix("#/components/schemas/")
404                .unwrap_or_else(|| panic!("ref {target} is not a components ref"));
405            assert!(schemas.contains_key(name), "dangling ref: {target}");
406        }
407
408        let serialised = serde_json::to_string(&doc).unwrap();
409        assert!(!serialised.contains("$defs"), "no $defs may survive into the document");
410    }
411
412    #[test]
413    fn servers_is_present_but_not_a_real_host() {
414        // Linters error on absent `servers`; a plausible https:// URL would
415        // invite the mistake info.description exists to prevent.
416        let doc = build_openapi(&sample_data(), false).unwrap();
417        assert_eq!(doc["servers"][0]["url"], "/");
418        assert!(
419            doc["servers"][0]["description"]
420                .as_str()
421                .unwrap()
422                .contains("Not a real server")
423        );
424    }
425
426    #[test]
427    fn the_synthetic_path_warning_is_present() {
428        let doc = build_openapi(&sample_data(), false).unwrap();
429        let description = doc["info"]["description"].as_str().unwrap();
430        assert!(description.contains("NOT A SERVABLE HTTP API"));
431        assert!(description.contains("AsyncAPI"));
432    }
433
434    #[test]
435    fn every_operation_carries_security() {
436        let doc = build_openapi(&sample_data(), false).unwrap();
437        let op = &doc["paths"]["/userApi/user_login"]["post"];
438        assert_eq!(op["security"], json!([{ "sessionToken": [] }]));
439        assert!(doc["components"]["securitySchemes"]["sessionToken"].is_object());
440    }
441}