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