Skip to main content

openapi_nexus/generators/
response_headers.rs

1//! Shared planning for OpenAPI-declared response-header accessors.
2//!
3//! Generated clients always retain the backend's complete native header
4//! collection. These plans only add optional typed convenience accessors for
5//! headers declared in the OpenAPI document.
6
7use std::collections::{BTreeMap, HashSet};
8
9use crate::ir::types::{
10    IrEnumValueType, IrPrimitive, IrResponse, IrSchemaKind, IrSpec, IrTypeExpr,
11};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum ResponseHeaderValueKind {
15    String,
16    Integer,
17    Number,
18    Boolean,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct ResponseHeaderPlan {
23    pub wire_name: String,
24    pub value_kind: ResponseHeaderValueKind,
25}
26
27/// Build collision-safe accessor names from a generator-specific preferred name.
28///
29/// Distinct legal HTTP header names can normalize to the same source-language
30/// identifier (for example, `X-Foo` and `X_Foo`). The first keeps the preferred
31/// name and later collisions receive a numeric suffix.
32pub fn unique_response_header_accessor_names(
33    headers: &[ResponseHeaderPlan],
34    preferred_name: impl FnMut(&str) -> String,
35) -> Vec<String> {
36    let mut used = HashSet::new();
37    unique_response_header_accessor_names_with_used(headers, preferred_name, &mut used)
38}
39
40/// Build accessor names while sharing a namespace with other header groups.
41pub fn unique_response_header_accessor_names_with_used(
42    headers: &[ResponseHeaderPlan],
43    mut preferred_name: impl FnMut(&str) -> String,
44    used: &mut HashSet<String>,
45) -> Vec<String> {
46    headers
47        .iter()
48        .map(|header| {
49            let preferred = preferred_name(&header.wire_name);
50            if used.insert(preferred.clone()) {
51                return preferred;
52            }
53            for index in 2..=u32::MAX {
54                let candidate = format!("{preferred}{index}");
55                if used.insert(candidate.clone()) {
56                    return candidate;
57                }
58            }
59            unreachable!("response header accessor name space exhausted")
60        })
61        .collect()
62}
63
64/// Collect headers declared by the selected response entries.
65///
66/// Header names are merged case-insensitively. If different response entries
67/// declare incompatible schemas for the same header, the accessor falls back
68/// to a string so callers can still read the wire value without lossy coercion.
69pub fn collect_response_headers<'a>(
70    responses: impl IntoIterator<Item = &'a IrResponse>,
71    ir: &IrSpec,
72) -> Vec<ResponseHeaderPlan> {
73    let mut headers: BTreeMap<String, ResponseHeaderPlan> = BTreeMap::new();
74    for response in responses {
75        for (wire_name, header) in &response.headers {
76            let key = wire_name.to_ascii_lowercase();
77            let value_kind = response_header_value_kind(&header.type_expr, ir);
78            headers
79                .entry(key)
80                .and_modify(|existing| {
81                    if existing.value_kind != value_kind {
82                        existing.value_kind = ResponseHeaderValueKind::String;
83                    }
84                })
85                .or_insert_with(|| ResponseHeaderPlan {
86                    wire_name: wire_name.clone(),
87                    value_kind,
88                });
89        }
90    }
91    headers.into_values().collect()
92}
93
94fn response_header_value_kind(expr: &IrTypeExpr, ir: &IrSpec) -> ResponseHeaderValueKind {
95    response_header_value_kind_inner(expr, ir, &mut HashSet::new())
96}
97
98fn response_header_value_kind_inner(
99    expr: &IrTypeExpr,
100    ir: &IrSpec,
101    seen: &mut HashSet<String>,
102) -> ResponseHeaderValueKind {
103    match expr {
104        IrTypeExpr::Primitive(IrPrimitive::Integer | IrPrimitive::IntegerWithFormat(_)) => {
105            ResponseHeaderValueKind::Integer
106        }
107        IrTypeExpr::Primitive(IrPrimitive::Number | IrPrimitive::NumberWithFormat(_)) => {
108            ResponseHeaderValueKind::Number
109        }
110        IrTypeExpr::Primitive(IrPrimitive::Boolean) => ResponseHeaderValueKind::Boolean,
111        IrTypeExpr::Primitive(_)
112        | IrTypeExpr::StringLiteral(_)
113        | IrTypeExpr::StringEnum(_)
114        | IrTypeExpr::Array(_)
115        | IrTypeExpr::Map(_)
116        | IrTypeExpr::Any => ResponseHeaderValueKind::String,
117        IrTypeExpr::Nullable(inner) => response_header_value_kind_inner(inner, ir, seen),
118        IrTypeExpr::Union(members) => {
119            let mut kinds = members
120                .iter()
121                .map(|member| response_header_value_kind_inner(member, ir, seen));
122            let Some(first) = kinds.next() else {
123                return ResponseHeaderValueKind::String;
124            };
125            if kinds.all(|kind| kind == first) {
126                first
127            } else {
128                ResponseHeaderValueKind::String
129            }
130        }
131        IrTypeExpr::Named(name) => {
132            if !seen.insert(name.clone()) {
133                return ResponseHeaderValueKind::String;
134            }
135            let kind = match ir.schemas.get(name).map(|schema| &schema.kind) {
136                Some(IrSchemaKind::Alias(inner)) => {
137                    response_header_value_kind_inner(inner, ir, seen)
138                }
139                Some(IrSchemaKind::Enum(enumeration)) => match enumeration.value_type {
140                    IrEnumValueType::Integer => ResponseHeaderValueKind::Integer,
141                    IrEnumValueType::Number => ResponseHeaderValueKind::Number,
142                    IrEnumValueType::String | IrEnumValueType::Mixed => {
143                        ResponseHeaderValueKind::String
144                    }
145                },
146                _ => ResponseHeaderValueKind::String,
147            };
148            seen.remove(name);
149            kind
150        }
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn accessor_names_are_unique_after_identifier_normalization() {
160        let headers = vec![
161            ResponseHeaderPlan {
162                wire_name: "X-Foo".to_string(),
163                value_kind: ResponseHeaderValueKind::String,
164            },
165            ResponseHeaderPlan {
166                wire_name: "X-Foo-Header2".to_string(),
167                value_kind: ResponseHeaderValueKind::String,
168            },
169            ResponseHeaderPlan {
170                wire_name: "X_Foo".to_string(),
171                value_kind: ResponseHeaderValueKind::String,
172            },
173        ];
174
175        let names = unique_response_header_accessor_names(&headers, |wire_name| {
176            format!("{}Header", wire_name.replace(['-', '_'], ""))
177        });
178
179        assert_eq!(names, ["XFooHeader", "XFooHeader2Header", "XFooHeader2"]);
180    }
181
182    #[test]
183    fn accessor_names_can_share_a_generated_module_namespace() {
184        let headers = vec![ResponseHeaderPlan {
185            wire_name: "Bar".to_string(),
186            value_kind: ResponseHeaderValueKind::String,
187        }];
188        let mut used = HashSet::new();
189
190        let first = unique_response_header_accessor_names_with_used(
191            &headers,
192            |_| "getGetFooBarHeader".to_string(),
193            &mut used,
194        );
195        let second = unique_response_header_accessor_names_with_used(
196            &headers,
197            |_| "getGetFooBarHeader".to_string(),
198            &mut used,
199        );
200
201        assert_eq!(first, ["getGetFooBarHeader"]);
202        assert_eq!(second, ["getGetFooBarHeader2"]);
203    }
204}