1use 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
36const SESSION_TOKEN_SCHEME: &str = "sessionToken";
38
39pub 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, ®istry)?;
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 ®istry,
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 "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
105pub 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
118const 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
136fn 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 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
218fn 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 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 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}