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