Skip to main content

openapi_trait_shared/codegen/
security.rs

1//! `OpenAPI` security-scheme support.
2//!
3//! Models the subset of `OpenAPI` 3 security used by v0.1 codegen:
4//!
5//! - `apiKey` schemes (`in: header | query | cookie`)
6//! - `http` schemes with `scheme: bearer` or `scheme: basic`
7//!
8//! `oauth2` and `openIdConnect` are recognised at parse time and silently
9//! skipped — operations that reference them behave as if they had no security
10//! requirement so the rest of the spec still compiles. Multi-scheme `AND`
11//! requirements (a single requirement object naming more than one scheme) emit
12//! a `compile_error!` because v0.1 only supports `OR` of single-scheme
13//! alternatives.
14
15use heck::{ToPascalCase, ToSnakeCase};
16use openapiv3::{APIKeyLocation, OpenAPI, Operation, ReferenceOr, SecurityScheme};
17use proc_macro2::TokenStream;
18use quote::{format_ident, quote};
19
20/// Where an `apiKey` credential is carried.
21#[derive(Debug, Clone, Copy)]
22pub enum ApiKeyIn {
23    Header,
24    Query,
25    Cookie,
26}
27
28/// Concrete v0.1-supported scheme variants.
29#[derive(Debug, Clone)]
30pub enum SchemeKind {
31    ApiKey { key: String, location: ApiKeyIn },
32    HttpBearer,
33    HttpBasic,
34}
35
36/// One declared scheme from `components.securitySchemes`.
37#[derive(Debug, Clone)]
38pub struct SchemeInfo {
39    /// Raw key from `components.securitySchemes`.
40    pub name: String,
41    /// `PascalCase` Rust ident used for the generated type.
42    pub ident: syn::Ident,
43    /// Snake-cased fragment used to derive builder method / state-field names.
44    pub snake: String,
45    pub kind: SchemeKind,
46}
47
48/// Resolved security requirements for one operation.
49#[derive(Debug, Clone, Default)]
50pub struct OpSecurity {
51    /// One scheme name per alternative (OR). Empty = no auth required.
52    pub alternatives: Vec<String>,
53    /// True if a multi-scheme AND requirement was rejected for this op.
54    pub had_unsupported_and: bool,
55}
56
57/// Read `components.securitySchemes` and keep only schemes supported in v0.1.
58#[must_use]
59pub fn collect_schemes(openapi: &OpenAPI) -> Vec<SchemeInfo> {
60    let Some(components) = openapi.components.as_ref() else {
61        return Vec::new();
62    };
63    let mut out = Vec::new();
64    for (name, ref_or) in &components.security_schemes {
65        let scheme = match ref_or {
66            ReferenceOr::Item(s) => s,
67            ReferenceOr::Reference { .. } => continue,
68        };
69        let kind = match scheme {
70            SecurityScheme::APIKey { location, name, .. } => {
71                let loc = match location {
72                    APIKeyLocation::Header => ApiKeyIn::Header,
73                    APIKeyLocation::Query => ApiKeyIn::Query,
74                    APIKeyLocation::Cookie => ApiKeyIn::Cookie,
75                };
76                SchemeKind::ApiKey {
77                    key: name.clone(),
78                    location: loc,
79                }
80            }
81            SecurityScheme::HTTP { scheme, .. } => match scheme.to_ascii_lowercase().as_str() {
82                "bearer" => SchemeKind::HttpBearer,
83                "basic" => SchemeKind::HttpBasic,
84                _ => continue,
85            },
86            SecurityScheme::OAuth2 { .. } | SecurityScheme::OpenIDConnect { .. } => continue,
87        };
88        let ident = format_ident!("{}", name.to_pascal_case());
89        let snake = name.to_snake_case();
90        out.push(SchemeInfo {
91            name: name.clone(),
92            ident,
93            snake,
94            kind,
95        });
96    }
97    out
98}
99
100/// Resolve the effective security requirements for an operation.
101///
102/// Semantics:
103/// - `operation.security == Some(vec![])` → explicit disable (no auth).
104/// - `operation.security == Some(non-empty)` → use op-level requirements.
105/// - `operation.security == None` → inherit `openapi.security` (doc-level default).
106///
107/// Each requirement object must reference exactly one supported scheme name to
108/// be accepted; requirements naming unknown schemes are dropped, and multi-key
109/// requirements (AND) are flagged via `had_unsupported_and` and otherwise dropped.
110#[must_use]
111pub fn resolve_op_security(
112    op: &Operation,
113    openapi: &OpenAPI,
114    schemes: &[SchemeInfo],
115) -> OpSecurity {
116    let requirements = match op.security.as_ref() {
117        Some(v) => v,
118        None => match openapi.security.as_ref() {
119            Some(v) => v,
120            None => return OpSecurity::default(),
121        },
122    };
123
124    let mut out = OpSecurity::default();
125    for req in requirements {
126        if req.len() > 1 {
127            out.had_unsupported_and = true;
128            continue;
129        }
130        let Some((name, _scopes)) = req.iter().next() else {
131            continue;
132        };
133        if scheme_by_name(schemes, name).is_some() {
134            out.alternatives.push(name.clone());
135        }
136    }
137    out
138}
139
140/// Look up a scheme by its raw name.
141#[must_use]
142pub fn scheme_by_name<'a>(schemes: &'a [SchemeInfo], name: &str) -> Option<&'a SchemeInfo> {
143    schemes.iter().find(|s| s.name == name)
144}
145
146/// Generate the top-level type definition for every declared scheme.
147#[must_use]
148pub fn generate_scheme_types(schemes: &[SchemeInfo]) -> TokenStream {
149    let items: Vec<TokenStream> = schemes
150        .iter()
151        .map(|s| {
152            let ident = &s.ident;
153            match &s.kind {
154                SchemeKind::ApiKey { .. } | SchemeKind::HttpBearer => quote! {
155                    #[derive(::core::fmt::Debug, ::core::clone::Clone)]
156                    pub struct #ident(pub ::std::string::String);
157                },
158                SchemeKind::HttpBasic => quote! {
159                    #[derive(::core::fmt::Debug, ::core::clone::Clone)]
160                    pub struct #ident {
161                        pub username: ::std::string::String,
162                        pub password: ::std::string::String,
163                    }
164                },
165            }
166        })
167        .collect();
168    quote! { #(#items)* }
169}
170
171/// Generate one `{Op}Auth` enum per operation that has more than one alternative.
172#[must_use]
173pub fn generate_op_auth_enum(op_id: &str, alternatives: &[&SchemeInfo]) -> Option<TokenStream> {
174    if alternatives.len() < 2 {
175        return None;
176    }
177    let ident = auth_enum_ident(op_id);
178    let variants: Vec<TokenStream> = alternatives
179        .iter()
180        .map(|s| {
181            let variant = &s.ident;
182            let ty = &s.ident;
183            quote! { #variant(#ty), }
184        })
185        .collect();
186    Some(quote! {
187        #[derive(::core::fmt::Debug, ::core::clone::Clone)]
188        pub enum #ident {
189            #(#variants)*
190        }
191    })
192}
193
194/// Pascal-cased ident for an operation's auth enum.
195#[must_use]
196pub fn auth_enum_ident(op_id: &str) -> syn::Ident {
197    format_ident!("{}Auth", op_id.to_pascal_case())
198}
199
200/// Compute the Rust type used as the trait method's `auth` parameter.
201///
202/// Returns `None` when the operation has no security requirement.
203#[must_use]
204pub fn auth_param_type(op_id: &str, op_security: &OpSecurity) -> Option<TokenStream> {
205    if op_security.alternatives.is_empty() {
206        return None;
207    }
208    if op_security.alternatives.len() == 1 {
209        return Some(TokenStream::new()); // sentinel: caller looks up the scheme ident
210    }
211    let ident = auth_enum_ident(op_id);
212    Some(quote! { #ident })
213}
214
215/// Returns the scheme infos referenced by an op's alternatives, in declaration order.
216#[must_use]
217pub fn resolve_alternatives<'a>(
218    op_security: &'a OpSecurity,
219    schemes: &'a [SchemeInfo],
220) -> Vec<&'a SchemeInfo> {
221    op_security
222        .alternatives
223        .iter()
224        .filter_map(|name| scheme_by_name(schemes, name))
225        .collect()
226}
227
228/// Module-level identifier for the generated client auth-state struct.
229#[must_use]
230pub fn auth_state_ident(mod_ident: &syn::Ident) -> syn::Ident {
231    format_ident!("{}AuthState", mod_ident.to_string().to_pascal_case())
232}
233
234/// Module-level identifier for the generated client auth extension trait.
235#[must_use]
236pub fn client_auth_trait_ident(mod_ident: &syn::Ident) -> syn::Ident {
237    format_ident!("{}ClientAuth", mod_ident.to_string().to_pascal_case())
238}
239
240/// Per-scheme snake-case suffix for builder methods / state fields.
241#[must_use]
242pub fn scheme_field_ident(scheme: &SchemeInfo) -> syn::Ident {
243    format_ident!("{}", scheme.snake)
244}
245
246/// Emit a `compile_error!` token if any operation referenced a multi-scheme AND
247/// requirement that v0.1 cannot represent.
248#[must_use]
249pub fn generate_unsupported_and_errors(ops_with_and: &[String]) -> TokenStream {
250    if ops_with_and.is_empty() {
251        return TokenStream::new();
252    }
253    let msgs: Vec<TokenStream> = ops_with_and
254        .iter()
255        .map(|op_id| {
256            let msg = format!(
257                "openapi-trait: operation `{op_id}` requires multiple security schemes simultaneously (AND); v0.1 only supports OR of single-scheme alternatives"
258            );
259            quote! { ::core::compile_error!(#msg); }
260        })
261        .collect();
262    quote! { #(#msgs)* }
263}