@@ -5,6 +5,7 @@ use quote::format_ident;
55use quote:: quote;
66
77use crate :: emit:: doc_attr;
8+ use crate :: emit:: doc_lines;
89use crate :: emit:: emit_type;
910use crate :: error:: Result ;
1011use crate :: ir:: BodyKind ;
@@ -23,6 +24,8 @@ use crate::ir::ResponseCase;
2324use crate :: ir:: ResponseHeader ;
2425use crate :: ir:: ResponseStatus ;
2526use crate :: ir:: RustType ;
27+ use crate :: ir:: SecurityScheme ;
28+ use crate :: ir:: SecuritySchemeKind ;
2629use crate :: ir:: Service ;
2730use crate :: naming:: operations:: axum_handler_name;
2831
@@ -127,7 +130,7 @@ fn emit_trait(service: &Service) -> Result<TokenStream> {
127130 let mut methods = Vec :: with_capacity ( service. operations . len ( ) ) ;
128131 for operation in & service. operations {
129132 let name = operation. name . to_token ( ) ;
130- let doc = doc_attr ( & operation. doc ) ;
133+ let doc = method_doc ( operation, & service . security_schemes ) ;
131134 let response = operation. response_enum . to_token ( ) ;
132135 let args = emit_method_args ( operation) ?;
133136 methods. push ( quote ! {
@@ -143,6 +146,74 @@ fn emit_trait(service: &Service) -> Result<TokenStream> {
143146 } ) ;
144147}
145148
149+ /// The doc comment of a trait method: the operation's own description, then the
150+ /// security the document names for it.
151+ ///
152+ /// The generator emits no check for that security, because verifying a
153+ /// credential needs application knowledge it does not have: which key, which
154+ /// issuer, and which claim names which user. Naming the schemes is what it can
155+ /// do, so an implementer does not have to read the document to tell a public
156+ /// operation from a protected one.
157+ fn method_doc ( operation : & Operation , schemes : & [ SecurityScheme ] ) -> TokenStream {
158+ if operation. security . is_empty ( ) {
159+ return doc_attr ( & operation. doc ) ;
160+ }
161+
162+ let mut lines: Vec < String > = Vec :: new ( ) ;
163+ if let Some ( text) = & operation. doc {
164+ lines. push ( text. clone ( ) ) ;
165+ lines. push ( String :: new ( ) ) ;
166+ }
167+ lines. push ( "# Security" . to_owned ( ) ) ;
168+ lines. push ( String :: new ( ) ) ;
169+ lines. push ( "The document names these security schemes for this operation:" . to_owned ( ) ) ;
170+ lines. push ( String :: new ( ) ) ;
171+ for key in & operation. security {
172+ lines. push ( format ! ( "- {}" , requirement_line( key, schemes) ) ) ;
173+ }
174+ lines. push ( String :: new ( ) ) ;
175+ if operation. security . len ( ) > 1 {
176+ // `security::required_keys` unions the alternatives and the
177+ // conjunctions, so past one key the list no longer says which it was.
178+ lines. push (
179+ "This list is the union of every alternative the document gives, so it may be a choice between schemes rather than all of them. Read `security` in the document for the exact rule."
180+ . to_owned ( ) ,
181+ ) ;
182+ lines. push ( String :: new ( ) ) ;
183+ }
184+ lines. push (
185+ "This generator emits no check. Enforce it in a layer around the router: this method receives only the parameters the operation declares, not the credential."
186+ . to_owned ( ) ,
187+ ) ;
188+ return doc_lines ( & lines) ;
189+ }
190+
191+ /// One security scheme, named and located.
192+ ///
193+ /// Where the credential sits is the part a server needs, because it has to read
194+ /// the credential itself.
195+ fn requirement_line ( key : & str , schemes : & [ SecurityScheme ] ) -> String {
196+ let Some ( scheme) = schemes. iter ( ) . find ( |scheme| return scheme. key == key) else {
197+ // The document names a scheme it never declares. The client rejects
198+ // that; a server has nothing to reject, so the doc says what it knows.
199+ return format ! ( "`{key}`, which `components.securitySchemes` does not declare" ) ;
200+ } ;
201+ let location = match & scheme. kind {
202+ SecuritySchemeKind :: HttpBearer => "a bearer token in the `Authorization` header" . to_owned ( ) ,
203+ SecuritySchemeKind :: HttpBasic => "basic credentials in the `Authorization` header" . to_owned ( ) ,
204+ SecuritySchemeKind :: ApiKeyHeader ( name) => format ! ( "an API key in the `{name}` header" ) ,
205+ SecuritySchemeKind :: ApiKeyQuery ( name) => format ! ( "an API key in the `{name}` query parameter" ) ,
206+ SecuritySchemeKind :: ApiKeyCookie ( name) => format ! ( "an API key in the `{name}` cookie" ) ,
207+ // The reason held here is written for the client, which refuses to send
208+ // such a credential. A server reads credentials rather than sending
209+ // them, so the key alone is what this can honestly state.
210+ SecuritySchemeKind :: Unsupported ( _) => {
211+ return format ! ( "`{key}`, a scheme this generator has no built-in support for" ) ;
212+ }
213+ } ;
214+ return format ! ( "`{key}`: {location}" ) ;
215+ }
216+
146217/// The typed arguments (path parameters, query struct, header struct, then JSON
147218/// body) of an operation method.
148219fn emit_method_args ( operation : & Operation ) -> Result < Vec < TokenStream > > {
@@ -892,3 +963,64 @@ fn request_content_type_test(kind: BodyKind) -> TokenStream {
892963 }
893964 } ;
894965}
966+
967+ #[ cfg( test) ]
968+ mod tests {
969+ use super :: * ;
970+ use crate :: naming:: Case ;
971+ use crate :: naming:: to_ident;
972+
973+ fn scheme ( key : & str , kind : SecuritySchemeKind ) -> SecurityScheme {
974+ return SecurityScheme {
975+ key : key. to_owned ( ) ,
976+ field : to_ident ( key, Case :: Snake ) ,
977+ kind,
978+ doc : None ,
979+ } ;
980+ }
981+
982+ #[ test]
983+ fn every_scheme_kind_says_where_the_credential_sits ( ) {
984+ let schemes = vec ! [
985+ scheme( "bearerAuth" , SecuritySchemeKind :: HttpBearer ) ,
986+ scheme( "basicAuth" , SecuritySchemeKind :: HttpBasic ) ,
987+ scheme( "headerKey" , SecuritySchemeKind :: ApiKeyHeader ( "X-API-Key" . to_owned( ) ) ) ,
988+ scheme( "queryKey" , SecuritySchemeKind :: ApiKeyQuery ( "api_key" . to_owned( ) ) ) ,
989+ scheme( "cookieKey" , SecuritySchemeKind :: ApiKeyCookie ( "SESSION" . to_owned( ) ) ) ,
990+ ] ;
991+ let cases = [
992+ (
993+ "bearerAuth" ,
994+ "`bearerAuth`: a bearer token in the `Authorization` header" ,
995+ ) ,
996+ (
997+ "basicAuth" ,
998+ "`basicAuth`: basic credentials in the `Authorization` header" ,
999+ ) ,
1000+ ( "headerKey" , "`headerKey`: an API key in the `X-API-Key` header" ) ,
1001+ ( "queryKey" , "`queryKey`: an API key in the `api_key` query parameter" ) ,
1002+ ( "cookieKey" , "`cookieKey`: an API key in the `SESSION` cookie" ) ,
1003+ ] ;
1004+ for ( key, expected) in cases {
1005+ assert_eq ! ( requirement_line( key, & schemes) , expected) ;
1006+ }
1007+ }
1008+
1009+ /// The client rejects a scheme the document never declares. A server has
1010+ /// nothing to reject, so the note has to stand on the key alone.
1011+ #[ test]
1012+ fn an_undeclared_scheme_is_still_named ( ) {
1013+ let line = requirement_line ( "ghost" , & [ ] ) ;
1014+ assert_eq ! ( line, "`ghost`, which `components.securitySchemes` does not declare" ) ;
1015+ }
1016+
1017+ #[ test]
1018+ fn an_unsupported_scheme_drops_the_client_side_reason ( ) {
1019+ let schemes = vec ! [ scheme(
1020+ "oauth2" ,
1021+ SecuritySchemeKind :: Unsupported ( "the client cannot send this" . to_owned( ) ) ,
1022+ ) ] ;
1023+ let line = requirement_line ( "oauth2" , & schemes) ;
1024+ assert_eq ! ( line, "`oauth2`, a scheme this generator has no built-in support for" ) ;
1025+ }
1026+ }
0 commit comments