Skip to main content

structured_proxy/
openapi.rs

1//! OpenAPI 3.0 spec generation from proto descriptors.
2//!
3//! Reads `google.api.http` annotations and proto message definitions
4//! to produce a complete OpenAPI 3.0 JSON spec at runtime.
5//! No codegen, no build step — same descriptor pool used for transcoding.
6
7use 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
19/// The operations an OpenAPI 3.0 path item can hold, in the order a `*` rule
20/// lists them.
21const OPERATIONS: [&str; 8] = [
22    "get", "put", "post", "delete", "options", "head", "patch", "trace",
23];
24
25/// Generate OpenAPI 3.0 JSON spec from a descriptor pool: the RPCs
26/// `selection` transcodes, and their services as tags.
27pub 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        // Proto comments as tag description.
50        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            // No REST mapping for client-streaming.
62            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    // Security scheme for Bearer auth (cookie auth works implicitly via same-origin).
118    spec["components"]["securitySchemes"] = json!({
119        "bearerAuth": {
120            "type": "http",
121            "scheme": "bearer",
122            "bearerFormat": "JWT",
123        },
124    });
125
126    spec
127}
128
129/// Generate Scalar API docs HTML page.
130pub 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
149/// The OpenAPI operations a binding appears under: every one for a `*` rule,
150/// the method's own for a method OpenAPI 3.0 has an operation for, and none for
151/// any other method token (OpenAPI 3.0 cannot describe it).
152fn 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
170/// Drop the content of every response: a HEAD response carries none
171/// (RFC 9110 §9.3.2).
172fn 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
182/// `base`, or `base_2`, `base_3`, ... when taken: OpenAPI requires every
183/// `operationId` to be unique, and one RPC can be mounted several times
184/// (additional bindings, aliases, a `*` rule).
185fn 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
202/// Content of a raw `google.api.HttpBody`: any media type, as bytes.
203fn raw_content() -> Value {
204    json!({ "*/*": { "schema": { "type": "string", "format": "binary" } } })
205}
206
207/// The operation for one binding of `method`, without its `operationId`.
208fn 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    // Description from proto comments.
220    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    // Path parameters.
232    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            // Try to get type from input message field.
243            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    // The body rule decides which fields travel in the body; every field
251    // bound by neither the path nor the body is a query parameter.
252    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    // With `body: "*"` the whole message is the body: no query parameters.
291    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                    // A message-typed parameter refers to its schema by `$ref`.
300                    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    // Response.
315    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        // The answer is only the field `response_body` names.
339        let schema = match response_field(&output, path) {
340            Some(field) => {
341                register_nested(&field, schemas);
342                field_to_schema(&field)
343            }
344            // The runtime answers JSON `null` for a path that names no field.
345            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    // Common error responses.
372    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
395/// The field a (possibly dotted) `response_body` path names in `output`,
396/// walking singular message fields.
397fn 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
413/// Register the schema of a message-typed field so its `$ref` resolves. A
414/// placeholder goes in before the fields are walked, so a message that
415/// contains itself (directly or through others) is referenced rather than
416/// expanded without end.
417fn 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
427/// Generate a JSON Schema for a protobuf message, excluding path parameter fields.
428fn 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    // Nested messages: register as separate schemas.
456    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        // Map<K, V> → object with additionalProperties.
510        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
563/// Extract the field names of the `{param}` / `{param=template}` captures of a
564/// path like `/v1/profiles/{profile_id}/devices`.
565fn 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                // `{name=shelves/*}` binds the field `name`.
579                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
592/// Get proto source comments for a given fully-qualified name.
593fn get_comments(_full_name: &str, _pool: &DescriptorPool) -> Option<String> {
594    // prost-reflect doesn't expose source code info comments easily.
595    // For now, return None. Can be enhanced with protoc-gen-doc or
596    // manual SourceCodeInfo parsing.
597    None
598}
599
600#[cfg(test)]
601mod tests;