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