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