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};
17
18/// The operations an OpenAPI 3.0 path item can hold, in the order a `*` rule
19/// lists them.
20const OPERATIONS: [&str; 8] = [
21    "get", "put", "post", "delete", "options", "head", "patch", "trace",
22];
23
24/// Generate OpenAPI 3.0 JSON spec from a descriptor pool.
25pub 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        // Proto comments as tag description.
40        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; // No REST mapping for client-streaming.
53            }
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    // Security scheme for Bearer auth (cookie auth works implicitly via same-origin).
107    spec["components"]["securitySchemes"] = json!({
108        "bearerAuth": {
109            "type": "http",
110            "scheme": "bearer",
111            "bearerFormat": "JWT",
112        },
113    });
114
115    spec
116}
117
118/// Generate Scalar API docs HTML page.
119pub 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
138/// The OpenAPI operations a binding appears under: every one for a `*` rule,
139/// the method's own for a method OpenAPI 3.0 has an operation for, and none for
140/// any other method token (OpenAPI 3.0 cannot describe it).
141fn 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
159/// Drop the content of every response: a HEAD response carries none
160/// (RFC 9110 §9.3.2).
161fn 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
171/// `base`, or `base_2`, `base_3`, ... when taken: OpenAPI requires every
172/// `operationId` to be unique, and one RPC can be mounted several times
173/// (additional bindings, aliases, a `*` rule).
174fn 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
191/// Content of a raw `google.api.HttpBody`: any media type, as bytes.
192fn raw_content() -> Value {
193    json!({ "*/*": { "schema": { "type": "string", "format": "binary" } } })
194}
195
196/// The operation for one binding of `method`, without its `operationId`.
197fn 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    // Description from proto comments.
209    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    // Path parameters.
221    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            // Try to get type from input message field.
232            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    // The body rule decides which fields travel in the body; every field
240    // bound by neither the path nor the body is a query parameter.
241    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    // With `body: "*"` the whole message is the body: no query parameters.
280    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                    // A message-typed parameter refers to its schema by `$ref`.
289                    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    // Response.
304    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        // The answer is only the field `response_body` names.
328        let schema = match response_field(&output, path) {
329            Some(field) => {
330                register_nested(&field, schemas);
331                field_to_schema(&field)
332            }
333            // The runtime answers JSON `null` for a path that names no field.
334            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    // Common error responses.
361    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
384/// The field a (possibly dotted) `response_body` path names in `output`,
385/// walking singular message fields.
386fn 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
402/// Register the schema of a message-typed field so its `$ref` resolves. A
403/// placeholder goes in before the fields are walked, so a message that
404/// contains itself (directly or through others) is referenced rather than
405/// expanded without end.
406fn 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
416/// Generate a JSON Schema for a protobuf message, excluding path parameter fields.
417fn 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    // Nested messages: register as separate schemas.
445    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        // Map<K, V> → object with additionalProperties.
499        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
552/// Extract the field names of the `{param}` / `{param=template}` captures of a
553/// path like `/v1/profiles/{profile_id}/devices`.
554fn 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                // `{name=shelves/*}` binds the field `name`.
568                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
581/// Get proto source comments for a given fully-qualified name.
582fn get_comments(_full_name: &str, _pool: &DescriptorPool) -> Option<String> {
583    // prost-reflect doesn't expose source code info comments easily.
584    // For now, return None. Can be enhanced with protoc-gen-doc or
585    // manual SourceCodeInfo parsing.
586    None
587}
588
589#[cfg(test)]
590mod tests;