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