1use std::collections::HashSet;
8
9use axum::http::Method;
10use prost_reflect::{DescriptorPool, FieldDescriptor, Kind, MessageDescriptor, MethodDescriptor};
11use serde_json::{json, Map, Value};
12
13use crate::config::{AliasConfig, OpenApiConfig};
14use crate::transcode::request::BodyMapping;
15use crate::transcode::rule::{self, HttpBinding, RouteMethod};
16use crate::transcode::RpcSelection;
17use crate::transcode::{self, httpbody, path};
18
19const OPERATIONS: [&str; 8] = [
22 "get", "put", "post", "delete", "options", "head", "patch", "trace",
23];
24
25pub fn generate(
28 pool: &DescriptorPool,
29 config: &OpenApiConfig,
30 aliases: &[AliasConfig],
31 selection: &RpcSelection,
32) -> Value {
33 let title = config.title.as_deref().unwrap_or("API");
34 let version = config.version.as_deref().unwrap_or("1.0.0");
35
36 let mut paths = Map::new();
37 let mut schemas = Map::new();
38 let mut tags = Vec::new();
39 let mut operation_ids = HashSet::new();
40 let http_ext = pool.get_extension_by_name("google.api.http");
41 let mounted = transcode::mounted_shapes(pool, aliases, selection);
44
45 for service in pool.services() {
46 if !selection.is_all() && !service.methods().any(|m| selection.selects(&m)) {
47 continue;
48 }
49 let service_name = service.name().to_string();
50 let service_full = service.full_name().to_string();
51
52 let tag_desc = get_comments(&service_full, pool);
54 let mut tag = json!({ "name": service_name });
55 if let Some(desc) = &tag_desc {
56 tag["description"] = json!(desc);
57 }
58 tags.push(tag);
59
60 let Some(http_ext) = &http_ext else {
61 continue;
62 };
63 for method in service.methods() {
64 if method.is_client_streaming() || !selection.selects(&method) {
66 continue;
67 }
68
69 for binding in rule::http_bindings(&method, http_ext) {
70 let operations = operation_methods(&binding.method);
71 let expanded = operations.len() > 1;
72 if operations.is_empty() {
73 continue;
74 }
75 let operation = build_operation(&method, &service_name, &binding, &mut schemas);
76 for http_method in operations {
77 let mut id = format!("{service_name}.{}", method.name());
78 if expanded {
79 id = format!("{id}_{http_method}");
80 }
81
82 let mut targets = vec![binding.path.clone()];
83 for alias in aliases {
84 if let Some(suffix) = binding.path.strip_prefix(&alias.to) {
85 if alias.from.ends_with("/{path}") {
86 let prefix = alias.from.trim_end_matches("/{path}");
87 targets.push(format!("{prefix}{suffix}"));
88 }
89 }
90 }
91 for path in targets {
92 let mount = path::MountedPath::new(&path);
93 if mount.routable().is_err() || !mounted.contains(&mount.shape) {
94 continue;
95 }
96 let mut operation = operation.clone();
97 if http_method == "head" {
98 strip_response_content(&mut operation);
99 }
100 operation["operationId"] = json!(unique_id(&mut operation_ids, &id));
101 add_path_operation(&mut paths, &path, http_method, operation);
102 }
103 }
104 }
105 }
106 }
107
108 let mut spec = json!({
109 "openapi": "3.0.3",
110 "info": {
111 "title": title,
112 "version": version,
113 },
114 "paths": paths,
115 "tags": tags,
116 });
117
118 if !schemas.is_empty() {
119 spec["components"] = json!({
120 "schemas": schemas,
121 });
122 }
123
124 spec["components"]["securitySchemes"] = json!({
126 "bearerAuth": {
127 "type": "http",
128 "scheme": "bearer",
129 "bearerFormat": "JWT",
130 },
131 });
132
133 spec
134}
135
136pub fn docs_html(openapi_path: &str, title: &str) -> String {
138 format!(
139 r#"<!DOCTYPE html>
140<html>
141<head>
142 <title>{title} — API Docs</title>
143 <meta charset="utf-8" />
144 <meta name="viewport" content="width=device-width, initial-scale=1" />
145</head>
146<body>
147 <script id="api-reference" data-url="{openapi_path}"></script>
148 <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
149</body>
150</html>"#,
151 title = title,
152 openapi_path = openapi_path,
153 )
154}
155
156fn operation_methods(method: &RouteMethod) -> Vec<&'static str> {
160 let RouteMethod::One(method) = method else {
161 return OPERATIONS.to_vec();
162 };
163 let operation = match *method {
164 Method::GET => "get",
165 Method::PUT => "put",
166 Method::POST => "post",
167 Method::DELETE => "delete",
168 Method::OPTIONS => "options",
169 Method::HEAD => "head",
170 Method::PATCH => "patch",
171 Method::TRACE => "trace",
172 _ => return Vec::new(),
173 };
174 vec![operation]
175}
176
177fn strip_response_content(operation: &mut Value) {
180 if let Some(responses) = operation["responses"].as_object_mut() {
181 for response in responses.values_mut() {
182 if let Some(response) = response.as_object_mut() {
183 response.remove("content");
184 }
185 }
186 }
187}
188
189fn unique_id(ids: &mut HashSet<String>, base: &str) -> String {
193 let mut id = base.to_owned();
194 let mut n = 1;
195 while !ids.insert(id.clone()) {
196 n += 1;
197 id = format!("{base}_{n}");
198 }
199 id
200}
201
202fn add_path_operation(paths: &mut Map<String, Value>, path: &str, method: &str, operation: Value) {
203 let path_item = paths.entry(path.to_string()).or_insert_with(|| json!({}));
204 if let Some(obj) = path_item.as_object_mut() {
205 obj.insert(method.to_string(), operation);
206 }
207}
208
209fn raw_content() -> Value {
211 json!({ "*/*": { "schema": { "type": "string", "format": "binary" } } })
212}
213
214fn build_operation(
216 method: &MethodDescriptor,
217 service_name: &str,
218 binding: &HttpBinding,
219 schemas: &mut Map<String, Value>,
220) -> Value {
221 let method_name = method.name().to_string();
222 let full_name = method.full_name().to_string();
223 let input = method.input();
224 let output = method.output();
225
226 let description = get_comments(&full_name, method.parent_pool()).unwrap_or_default();
228
229 let mut op = json!({
230 "tags": [service_name],
231 "summary": method_name,
232 });
233
234 if !description.is_empty() {
235 op["description"] = json!(description);
236 }
237
238 let path_params = extract_path_params(&binding.path);
240 let mut params: Vec<Value> = path_params
241 .iter()
242 .map(|name| {
243 let mut param = json!({
244 "name": name,
245 "in": "path",
246 "required": true,
247 "schema": { "type": "string" },
248 });
249 if let Some(field) = input.get_field_by_name(name) {
251 param["schema"] = field_to_schema(&field);
252 }
253 param
254 })
255 .collect();
256
257 match &binding.body {
260 BodyMapping::None => {}
261 BodyMapping::Root => {
262 let has_body_fields = input
263 .fields()
264 .any(|f| !path_params.contains(&f.name().to_string()));
265 if httpbody::is_http_body(&input) {
266 op["requestBody"] = json!({ "required": true, "content": raw_content() });
267 } else if has_body_fields {
268 let schema_name = input.name().to_string();
269 let body_schema = message_to_schema(&input, &path_params, schemas);
270 schemas.insert(schema_name.clone(), body_schema);
271 op["requestBody"] = json!({
272 "required": true,
273 "content": {
274 "application/json": {
275 "schema": { "$ref": format!("#/components/schemas/{}", schema_name) },
276 },
277 },
278 });
279 }
280 }
281 BodyMapping::Field(name) => {
282 if let Some(field) = input.get_field_by_name(name) {
283 let content = if httpbody::http_body_field(&input, name).is_some() {
284 raw_content()
285 } else {
286 register_nested(&field, schemas);
287 json!({ "application/json": { "schema": field_to_schema(&field) } })
288 };
289 op["requestBody"] = json!({ "required": true, "content": content });
290 }
291 }
292 }
293 let body_field = match &binding.body {
294 BodyMapping::Field(name) => Some(name.as_str()),
295 BodyMapping::None | BodyMapping::Root => None,
296 };
297 if binding.body != BodyMapping::Root {
299 params.extend(
300 input
301 .fields()
302 .filter(|f| {
303 !path_params.contains(&f.name().to_string()) && body_field != Some(f.name())
304 })
305 .map(|field| {
306 register_nested(&field, schemas);
308 json!({
309 "name": field.name(),
310 "in": "query",
311 "required": false,
312 "schema": field_to_schema(&field),
313 })
314 }),
315 );
316 }
317 if !params.is_empty() {
318 op["parameters"] = json!(params);
319 }
320
321 let raw_response = match &binding.response_body {
323 None => httpbody::is_http_body(&output),
324 Some(path) => httpbody::http_body_path(&output, path).is_some(),
325 };
326 op["responses"] = if raw_response {
327 let description = if method.is_server_streaming() {
328 "Server-streaming raw body (the data of every message, concatenated)"
329 } else {
330 "Success"
331 };
332 json!({ "200": { "description": description, "content": raw_content() } })
333 } else if method.is_server_streaming() {
334 json!({
335 "200": {
336 "description": "Server-streaming response (NDJSON)",
337 "content": {
338 "application/x-ndjson": {
339 "schema": message_ref_or_inline(&output, schemas),
340 },
341 },
342 },
343 })
344 } else if let Some(path) = &binding.response_body {
345 let schema = match response_field(&output, path) {
347 Some(field) => {
348 register_nested(&field, schemas);
349 field_to_schema(&field)
350 }
351 None => json!({ "nullable": true }),
353 };
354 json!({
355 "200": {
356 "description": "Success",
357 "content": { "application/json": { "schema": schema } },
358 },
359 })
360 } else if output.full_name() == "google.protobuf.Empty" {
361 json!({ "200": { "description": "Success (empty response)" } })
362 } else {
363 let schema_name = output.name().to_string();
364 let response_schema = message_to_schema(&output, &[], schemas);
365 schemas.insert(schema_name.clone(), response_schema);
366 json!({
367 "200": {
368 "description": "Success",
369 "content": {
370 "application/json": {
371 "schema": { "$ref": format!("#/components/schemas/{}", schema_name) },
372 },
373 },
374 },
375 })
376 };
377
378 if let Some(responses) = op.get_mut("responses").and_then(|r| r.as_object_mut()) {
380 responses.insert(
381 "400".to_string(),
382 json!({ "description": "Invalid argument" }),
383 );
384 responses.insert(
385 "401".to_string(),
386 json!({ "description": "Unauthenticated" }),
387 );
388 responses.insert(
389 "403".to_string(),
390 json!({ "description": "Permission denied" }),
391 );
392 responses.insert("404".to_string(), json!({ "description": "Not found" }));
393 responses.insert(
394 "503".to_string(),
395 json!({ "description": "Service unavailable" }),
396 );
397 }
398
399 op
400}
401
402fn response_field(output: &MessageDescriptor, path: &str) -> Option<FieldDescriptor> {
405 let mut desc = output.clone();
406 let mut segments = path.split('.').peekable();
407 while let Some(segment) = segments.next() {
408 let field = desc.get_field_by_name(segment)?;
409 if segments.peek().is_none() {
410 return Some(field);
411 }
412 match field.kind() {
413 Kind::Message(inner) if !field.is_list() && !field.is_map() => desc = inner,
414 _ => return None,
415 }
416 }
417 None
418}
419
420fn register_nested(field: &FieldDescriptor, schemas: &mut Map<String, Value>) {
425 if let Kind::Message(nested) = field.kind() {
426 if !is_well_known(&nested) && !schemas.contains_key(nested.name()) {
427 schemas.insert(nested.name().to_string(), json!({ "type": "object" }));
428 let nested_schema = message_to_schema(&nested, &[], schemas);
429 schemas.insert(nested.name().to_string(), nested_schema);
430 }
431 }
432}
433
434fn message_to_schema(
436 msg: &MessageDescriptor,
437 exclude_fields: &[String],
438 schemas: &mut Map<String, Value>,
439) -> Value {
440 let mut properties = Map::new();
441 let required: Vec<String> = Vec::new();
442
443 for field in msg.fields() {
444 let name = field.name().to_string();
445 if exclude_fields.contains(&name) {
446 continue;
447 }
448
449 let schema = field_to_schema(&field);
450 properties.insert(name, schema);
451 }
452
453 let mut schema = json!({
454 "type": "object",
455 "properties": properties,
456 });
457
458 if !required.is_empty() {
459 schema["required"] = json!(required);
460 }
461
462 for field in msg.fields() {
464 if exclude_fields.contains(&field.name().to_string()) {
465 continue;
466 }
467 register_nested(&field, schemas);
468 }
469
470 schema
471}
472
473fn message_ref_or_inline(msg: &MessageDescriptor, schemas: &mut Map<String, Value>) -> Value {
474 let name = msg.name().to_string();
475 if !schemas.contains_key(&name) {
476 let schema = message_to_schema(msg, &[], schemas);
477 schemas.insert(name.clone(), schema);
478 }
479 json!({ "$ref": format!("#/components/schemas/{}", name) })
480}
481
482fn field_to_schema(field: &FieldDescriptor) -> Value {
483 let base = match field.kind() {
484 Kind::Double | Kind::Float => json!({ "type": "number", "format": "double" }),
485 Kind::Int32 | Kind::Sint32 | Kind::Sfixed32 => {
486 json!({ "type": "integer", "format": "int32" })
487 }
488 Kind::Int64 | Kind::Sint64 | Kind::Sfixed64 => {
489 json!({ "type": "string", "format": "int64", "description": "64-bit integer (string-encoded)" })
490 }
491 Kind::Uint32 | Kind::Fixed32 => {
492 json!({ "type": "integer", "format": "uint32" })
493 }
494 Kind::Uint64 | Kind::Fixed64 => {
495 json!({ "type": "string", "format": "uint64", "description": "64-bit unsigned integer (string-encoded)" })
496 }
497 Kind::Bool => json!({ "type": "boolean" }),
498 Kind::String => json!({ "type": "string" }),
499 Kind::Bytes => json!({ "type": "string", "format": "byte" }),
500 Kind::Enum(e) => {
501 let values: Vec<Value> = e.values().map(|v| json!(v.name())).collect();
502 json!({ "type": "string", "enum": values })
503 }
504 Kind::Message(msg) => {
505 if is_well_known(&msg) {
506 well_known_schema(&msg)
507 } else {
508 json!({ "$ref": format!("#/components/schemas/{}", msg.name()) })
509 }
510 }
511 };
512
513 if field.is_list() {
514 json!({ "type": "array", "items": base })
515 } else if field.is_map() {
516 if let Kind::Message(entry) = field.kind() {
518 let value_field = entry.get_field_by_name("value");
519 let value_schema = value_field
520 .map(|f| field_to_schema(&f))
521 .unwrap_or_else(|| json!({}));
522 json!({ "type": "object", "additionalProperties": value_schema })
523 } else {
524 json!({ "type": "object" })
525 }
526 } else {
527 base
528 }
529}
530
531fn is_well_known(msg: &MessageDescriptor) -> bool {
532 msg.full_name().starts_with("google.protobuf.")
533}
534
535fn well_known_schema(msg: &MessageDescriptor) -> Value {
536 match msg.full_name() {
537 "google.protobuf.Timestamp" => {
538 json!({ "type": "string", "format": "date-time" })
539 }
540 "google.protobuf.Duration" => {
541 json!({ "type": "string", "format": "duration", "example": "3.5s" })
542 }
543 "google.protobuf.Empty" => json!({ "type": "object" }),
544 "google.protobuf.Struct" => json!({ "type": "object" }),
545 "google.protobuf.Value" => json!({}),
546 "google.protobuf.ListValue" => json!({ "type": "array", "items": {} }),
547 "google.protobuf.StringValue" | "google.protobuf.BytesValue" => {
548 json!({ "type": "string" })
549 }
550 "google.protobuf.BoolValue" => json!({ "type": "boolean" }),
551 "google.protobuf.Int32Value" | "google.protobuf.UInt32Value" => {
552 json!({ "type": "integer" })
553 }
554 "google.protobuf.Int64Value" | "google.protobuf.UInt64Value" => {
555 json!({ "type": "string", "format": "int64" })
556 }
557 "google.protobuf.FloatValue" | "google.protobuf.DoubleValue" => {
558 json!({ "type": "number" })
559 }
560 "google.protobuf.FieldMask" => {
561 json!({ "type": "string", "description": "Comma-separated field paths" })
562 }
563 "google.protobuf.Any" => {
564 json!({ "type": "object", "properties": { "@type": { "type": "string" } }, "additionalProperties": true })
565 }
566 _ => json!({ "type": "object" }),
567 }
568}
569
570fn extract_path_params(path: &str) -> Vec<String> {
573 let mut params = Vec::new();
574 let mut in_brace = false;
575 let mut current = String::new();
576
577 for ch in path.chars() {
578 match ch {
579 '{' => {
580 in_brace = true;
581 current.clear();
582 }
583 '}' => {
584 in_brace = false;
585 let name = current.split('=').next().unwrap_or_default();
587 if !name.is_empty() {
588 params.push(name.to_string());
589 }
590 }
591 _ if in_brace => current.push(ch),
592 _ => {}
593 }
594 }
595
596 params
597}
598
599fn get_comments(_full_name: &str, _pool: &DescriptorPool) -> Option<String> {
601 None
605}
606
607#[cfg(test)]
608mod tests;