Skip to main content

spikard_codegen/sql/
annotations.rs

1#![allow(
2    clippy::missing_errors_doc,
3    clippy::missing_panics_doc,
4    clippy::must_use_candidate,
5    clippy::doc_markdown,
6    clippy::too_long_first_doc_paragraph,
7    clippy::module_name_repetitions,
8    clippy::too_many_lines
9)]
10//! Spikard's HTTP annotation grammar, parsed out of scythe's `CustomAnnotation`
11//! slice. Scythe captures every unknown `-- @<name> <value>` line verbatim;
12//! spikard owns the vocabulary that turns those triples into route metadata.
13
14use std::collections::BTreeMap;
15
16use scythe_core::analyzer::AnalyzedQuery;
17use scythe_core::parser::CustomAnnotation;
18use scythe_core::parser::QueryCommand;
19use serde::{Deserialize, Serialize};
20use thiserror::Error;
21
22/// Errors raised while parsing HTTP annotations. Each variant carries the
23/// 1-based source line from the originating `CustomAnnotation` so messages can
24/// point users at the offending SQL.
25#[derive(Debug, Error, PartialEq, Eq)]
26pub enum AnnotationParseError {
27    #[error("line {line}: @http expects '<METHOD> <PATH>' (got '{value}')")]
28    MalformedHttp { line: usize, value: String },
29
30    #[error("line {line}: unknown HTTP method '{method}'")]
31    UnknownMethod { line: usize, method: String },
32
33    #[error("line {line}: duplicate @http directive (only one route per query)")]
34    DuplicateHttp { line: usize },
35
36    #[error("line {line}: @http_param expects '<name> <path|query|body|header>' (got '{value}')")]
37    MalformedHttpParam { line: usize, value: String },
38
39    #[error("line {line}: unknown @http_param binding '{binding}' (expected path/query/body/header)")]
40    UnknownBinding { line: usize, binding: String },
41
42    #[error("line {line}: @http_status expects comma-separated codes (got '{value}')")]
43    MalformedHttpStatus { line: usize, value: String },
44
45    #[error(
46        "line {line}: @http_auth expects 'none', 'bearer[:<format>]', or 'api_key:<location>:<name>' (got '{value}')"
47    )]
48    MalformedHttpAuth { line: usize, value: String },
49
50    #[error("line {line}: @http_auth api_key location must be header/query/cookie (got '{location}')")]
51    UnknownApiKeyLocation { line: usize, location: String },
52
53    #[error(
54        "command :{command} cannot be mapped to HTTP (only :one, :opt, :many, :exec, :exec_rows, :grouped are supported)"
55    )]
56    IncompatibleCommand { command: String },
57
58    #[error("command :{command} requires method {expected_methods:?} (got {actual_method})")]
59    MethodCommandMismatch {
60        command: String,
61        expected_methods: Vec<&'static str>,
62        actual_method: String,
63    },
64}
65
66/// HTTP method extracted from `@http <METHOD> <PATH>`.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
68#[serde(rename_all = "UPPERCASE")]
69pub enum HttpMethod {
70    Get,
71    Post,
72    Put,
73    Patch,
74    Delete,
75    Head,
76    Options,
77}
78
79impl HttpMethod {
80    pub const fn as_str(self) -> &'static str {
81        match self {
82            Self::Get => "GET",
83            Self::Post => "POST",
84            Self::Put => "PUT",
85            Self::Patch => "PATCH",
86            Self::Delete => "DELETE",
87            Self::Head => "HEAD",
88            Self::Options => "OPTIONS",
89        }
90    }
91
92    fn from_str(s: &str) -> Option<Self> {
93        match s.to_ascii_uppercase().as_str() {
94            "GET" => Some(Self::Get),
95            "POST" => Some(Self::Post),
96            "PUT" => Some(Self::Put),
97            "PATCH" => Some(Self::Patch),
98            "DELETE" => Some(Self::Delete),
99            "HEAD" => Some(Self::Head),
100            "OPTIONS" => Some(Self::Options),
101            _ => None,
102        }
103    }
104}
105
106/// Where an HTTP request parameter is sourced from.
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
108#[serde(rename_all = "lowercase")]
109pub enum HttpParamBinding {
110    Path,
111    Query,
112    Body,
113    Header,
114}
115
116/// Authentication requirement attached to a route, mapping directly to
117/// spikard's existing `SecuritySchemeInfo` enum (bearer-style HTTP auth or
118/// API-key auth).
119#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
120#[serde(tag = "kind", rename_all = "snake_case")]
121pub enum AuthRequirement {
122    None,
123    Bearer {
124        #[serde(skip_serializing_if = "Option::is_none")]
125        format: Option<String>,
126    },
127    ApiKey {
128        location: ApiKeyLocation,
129        name: String,
130    },
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
134#[serde(rename_all = "lowercase")]
135pub enum ApiKeyLocation {
136    Header,
137    Query,
138    Cookie,
139}
140
141/// Parsed HTTP metadata for a single SQL query.
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
143pub struct HttpAnnotations {
144    pub method: HttpMethod,
145    /// Path normalized to spikard's canonical `{name}` form. Both `:id` and
146    /// `{id}` are accepted in source and emitted as `{id}`.
147    pub path: String,
148    /// Explicit param-location overrides, keyed by parameter name. Names
149    /// absent from this map fall back to inference rules (see
150    /// [`bin_param_locations`](crate::sql::route)).
151    pub param_bindings: BTreeMap<String, HttpParamBinding>,
152    /// Name of the bundled body object when multiple body params exist.
153    pub request_body_name: Option<String>,
154    /// Status codes the route documents (defaults derived from the SQL
155    /// `QueryCommand` when empty).
156    pub status_codes: Vec<u16>,
157    pub auth: Option<AuthRequirement>,
158    pub tags: Vec<String>,
159    pub summary: Option<String>,
160    pub description: Option<String>,
161}
162
163/// Parse spikard's HTTP vocabulary out of the custom-annotation slice that
164/// scythe captured. Returns `Ok(None)` when no `@http` directive is present —
165/// queries without HTTP semantics co-exist in the same source tree.
166pub fn parse_http_annotations(custom: &[CustomAnnotation]) -> Result<Option<HttpAnnotations>, AnnotationParseError> {
167    let mut http: Option<(usize, HttpMethod, String)> = None;
168    let mut param_bindings: BTreeMap<String, HttpParamBinding> = BTreeMap::new();
169    let mut request_body_name: Option<String> = None;
170    let mut status_codes: Vec<u16> = Vec::new();
171    let mut auth: Option<AuthRequirement> = None;
172    let mut tags: Vec<String> = Vec::new();
173    let mut summary: Option<String> = None;
174    let mut description: Option<String> = None;
175
176    for ann in custom {
177        match ann.name.as_str() {
178            "http" => {
179                if http.is_some() {
180                    return Err(AnnotationParseError::DuplicateHttp { line: ann.line });
181                }
182                let (method_raw, path_raw) =
183                    ann.value
184                        .split_once(char::is_whitespace)
185                        .ok_or_else(|| AnnotationParseError::MalformedHttp {
186                            line: ann.line,
187                            value: ann.value.clone(),
188                        })?;
189                let method = HttpMethod::from_str(method_raw).ok_or_else(|| AnnotationParseError::UnknownMethod {
190                    line: ann.line,
191                    method: method_raw.to_string(),
192                })?;
193                let path = normalize_path(path_raw.trim());
194                if path.is_empty() {
195                    return Err(AnnotationParseError::MalformedHttp {
196                        line: ann.line,
197                        value: ann.value.clone(),
198                    });
199                }
200                http = Some((ann.line, method, path));
201            }
202            "http_param" => {
203                let (name, binding_raw) = ann.value.split_once(char::is_whitespace).ok_or_else(|| {
204                    AnnotationParseError::MalformedHttpParam {
205                        line: ann.line,
206                        value: ann.value.clone(),
207                    }
208                })?;
209                let binding =
210                    parse_binding(binding_raw.trim()).ok_or_else(|| AnnotationParseError::UnknownBinding {
211                        line: ann.line,
212                        binding: binding_raw.trim().to_string(),
213                    })?;
214                param_bindings.insert(name.trim().to_string(), binding);
215            }
216            "http_request_body" => {
217                let trimmed = ann.value.trim();
218                if !trimmed.is_empty() {
219                    request_body_name = Some(trimmed.to_string());
220                }
221            }
222            "http_status" => {
223                for code_raw in ann.value.split(',') {
224                    let trimmed = code_raw.trim();
225                    if trimmed.is_empty() {
226                        continue;
227                    }
228                    let code = trimmed
229                        .parse::<u16>()
230                        .map_err(|_| AnnotationParseError::MalformedHttpStatus {
231                            line: ann.line,
232                            value: ann.value.clone(),
233                        })?;
234                    status_codes.push(code);
235                }
236            }
237            "http_auth" => {
238                auth = Some(parse_auth(&ann.value, ann.line)?);
239            }
240            "http_tags" => {
241                for tag in ann.value.split(',') {
242                    let trimmed = tag.trim();
243                    if !trimmed.is_empty() {
244                        tags.push(trimmed.to_string());
245                    }
246                }
247            }
248            "http_summary" => {
249                summary = Some(ann.value.trim().to_string()).filter(|s| !s.is_empty());
250            }
251            "http_description" => {
252                description = Some(ann.value.trim().to_string()).filter(|s| !s.is_empty());
253            }
254            // Annotations spikard doesn't recognise are ignored here — they
255            // belong to some other consumer layered on top of scythe.
256            _ => {}
257        }
258    }
259
260    let Some((_, method, path)) = http else {
261        return Ok(None);
262    };
263
264    Ok(Some(HttpAnnotations {
265        method,
266        path,
267        param_bindings,
268        request_body_name,
269        status_codes,
270        auth,
271        tags,
272        summary,
273        description,
274    }))
275}
276
277/// Validate that the HTTP method declared on a query is compatible with the
278/// scythe `QueryCommand`, and return the default status code for the (command,
279/// method) pair when [`HttpAnnotations::status_codes`] is empty.
280pub fn default_status_for(command: &QueryCommand, method: HttpMethod) -> Result<u16, AnnotationParseError> {
281    let (allowed, default): (&[HttpMethod], u16) = match command {
282        QueryCommand::One | QueryCommand::Opt | QueryCommand::Many | QueryCommand::Grouped => (&[HttpMethod::Get], 200),
283        QueryCommand::Exec => (
284            &[HttpMethod::Post, HttpMethod::Put, HttpMethod::Patch, HttpMethod::Delete],
285            204,
286        ),
287        QueryCommand::ExecRows => (
288            &[HttpMethod::Post, HttpMethod::Put, HttpMethod::Patch, HttpMethod::Delete],
289            200,
290        ),
291        QueryCommand::ExecResult | QueryCommand::Batch => {
292            return Err(AnnotationParseError::IncompatibleCommand {
293                command: command.to_string(),
294            });
295        }
296    };
297
298    if !allowed.contains(&method) {
299        return Err(AnnotationParseError::MethodCommandMismatch {
300            command: command.to_string(),
301            expected_methods: allowed.iter().map(|m| m.as_str()).collect(),
302            actual_method: method.as_str().to_string(),
303        });
304    }
305    Ok(default)
306}
307
308/// Convenience: parse the HTTP annotations on an `AnalyzedQuery` AND validate
309/// the command/method combination in one call. Returns `Ok(None)` when the
310/// query has no `@http` directive.
311pub fn parse_for_query(query: &AnalyzedQuery) -> Result<Option<(HttpAnnotations, u16)>, AnnotationParseError> {
312    let Some(http) = parse_http_annotations(&query.custom)? else {
313        return Ok(None);
314    };
315    let default_status = default_status_for(&query.command, http.method)?;
316    Ok(Some((http, default_status)))
317}
318
319fn parse_binding(s: &str) -> Option<HttpParamBinding> {
320    match s.to_ascii_lowercase().as_str() {
321        "path" => Some(HttpParamBinding::Path),
322        "query" => Some(HttpParamBinding::Query),
323        "body" => Some(HttpParamBinding::Body),
324        "header" => Some(HttpParamBinding::Header),
325        _ => None,
326    }
327}
328
329fn parse_auth(value: &str, line: usize) -> Result<AuthRequirement, AnnotationParseError> {
330    let trimmed = value.trim();
331    if trimmed.eq_ignore_ascii_case("none") {
332        return Ok(AuthRequirement::None);
333    }
334    if let Some(rest) = trimmed
335        .strip_prefix("bearer")
336        .or_else(|| trimmed.strip_prefix("Bearer"))
337    {
338        let rest = rest.trim();
339        if rest.is_empty() {
340            return Ok(AuthRequirement::Bearer { format: None });
341        }
342        if let Some(format) = rest.strip_prefix(':') {
343            let format = format.trim();
344            if format.is_empty() {
345                return Ok(AuthRequirement::Bearer { format: None });
346            }
347            return Ok(AuthRequirement::Bearer {
348                format: Some(format.to_string()),
349            });
350        }
351        return Err(AnnotationParseError::MalformedHttpAuth {
352            line,
353            value: value.to_string(),
354        });
355    }
356    if let Some(rest) = trimmed
357        .strip_prefix("api_key")
358        .or_else(|| trimmed.strip_prefix("apikey"))
359    {
360        let rest = rest
361            .strip_prefix(':')
362            .ok_or_else(|| AnnotationParseError::MalformedHttpAuth {
363                line,
364                value: value.to_string(),
365            })?;
366        let (location_raw, name) = rest
367            .split_once(':')
368            .ok_or_else(|| AnnotationParseError::MalformedHttpAuth {
369                line,
370                value: value.to_string(),
371            })?;
372        let location = match location_raw.trim().to_ascii_lowercase().as_str() {
373            "header" => ApiKeyLocation::Header,
374            "query" => ApiKeyLocation::Query,
375            "cookie" => ApiKeyLocation::Cookie,
376            other => {
377                return Err(AnnotationParseError::UnknownApiKeyLocation {
378                    line,
379                    location: other.to_string(),
380                });
381            }
382        };
383        return Ok(AuthRequirement::ApiKey {
384            location,
385            name: name.trim().to_string(),
386        });
387    }
388    Err(AnnotationParseError::MalformedHttpAuth {
389        line,
390        value: value.to_string(),
391    })
392}
393
394/// Normalize an `@http` path so colon-prefixed placeholders (`:id`) become the
395/// brace-wrapped form (`{id}`) that spikard uses canonically. The brace form
396/// passes through unchanged.
397fn normalize_path(raw: &str) -> String {
398    let mut out = String::with_capacity(raw.len());
399    let bytes = raw.as_bytes();
400    let mut i = 0;
401    while i < bytes.len() {
402        let b = bytes[i];
403        if b == b':' && i + 1 < bytes.len() && is_ident_start(bytes[i + 1]) {
404            out.push('{');
405            i += 1;
406            while i < bytes.len() && is_ident_continue(bytes[i]) {
407                out.push(bytes[i] as char);
408                i += 1;
409            }
410            out.push('}');
411        } else {
412            out.push(b as char);
413            i += 1;
414        }
415    }
416    out
417}
418
419const fn is_ident_start(b: u8) -> bool {
420    b.is_ascii_alphabetic() || b == b'_'
421}
422
423const fn is_ident_continue(b: u8) -> bool {
424    b.is_ascii_alphanumeric() || b == b'_'
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430    use scythe_core::parser::CustomAnnotation;
431
432    fn ann(name: &str, value: &str, line: usize) -> CustomAnnotation {
433        CustomAnnotation {
434            name: name.to_string(),
435            value: value.to_string(),
436            line,
437        }
438    }
439
440    #[test]
441    fn returns_none_when_no_http_directive() {
442        let custom = vec![ann("http_auth", "bearer", 1)];
443        assert_eq!(parse_http_annotations(&custom).unwrap(), None);
444    }
445
446    #[test]
447    fn parses_basic_get_route() {
448        let custom = vec![ann("http", "GET /users/{id}", 3)];
449        let h = parse_http_annotations(&custom).unwrap().unwrap();
450        assert_eq!(h.method, HttpMethod::Get);
451        assert_eq!(h.path, "/users/{id}");
452    }
453
454    #[test]
455    fn normalizes_colon_placeholders_to_braces() {
456        let custom = vec![ann("http", "GET /users/:id/orders/:order_id", 1)];
457        let h = parse_http_annotations(&custom).unwrap().unwrap();
458        assert_eq!(h.path, "/users/{id}/orders/{order_id}");
459    }
460
461    #[test]
462    fn leaves_brace_placeholders_unchanged() {
463        let custom = vec![ann("http", "GET /users/{id}/orders/{order_id}", 1)];
464        let h = parse_http_annotations(&custom).unwrap().unwrap();
465        assert_eq!(h.path, "/users/{id}/orders/{order_id}");
466    }
467
468    #[test]
469    fn rejects_duplicate_http_directives() {
470        let custom = vec![ann("http", "GET /a", 1), ann("http", "GET /b", 2)];
471        assert!(matches!(
472            parse_http_annotations(&custom).unwrap_err(),
473            AnnotationParseError::DuplicateHttp { line: 2 }
474        ));
475    }
476
477    #[test]
478    fn rejects_unknown_method() {
479        let custom = vec![ann("http", "FETCH /users", 4)];
480        assert!(matches!(
481            parse_http_annotations(&custom).unwrap_err(),
482            AnnotationParseError::UnknownMethod { line: 4, .. }
483        ));
484    }
485
486    #[test]
487    fn parses_param_bindings() {
488        let custom = vec![
489            ann("http", "POST /users", 1),
490            ann("http_param", "id path", 2),
491            ann("http_param", "email body", 3),
492            ann("http_param", "limit query", 4),
493        ];
494        let h = parse_http_annotations(&custom).unwrap().unwrap();
495        assert_eq!(h.param_bindings.get("id"), Some(&HttpParamBinding::Path));
496        assert_eq!(h.param_bindings.get("email"), Some(&HttpParamBinding::Body));
497        assert_eq!(h.param_bindings.get("limit"), Some(&HttpParamBinding::Query));
498    }
499
500    #[test]
501    fn rejects_unknown_binding() {
502        let custom = vec![ann("http", "POST /x", 1), ann("http_param", "id foo", 5)];
503        assert!(matches!(
504            parse_http_annotations(&custom).unwrap_err(),
505            AnnotationParseError::UnknownBinding { line: 5, .. }
506        ));
507    }
508
509    #[test]
510    fn parses_status_codes() {
511        let custom = vec![ann("http", "GET /a", 1), ann("http_status", "200, 404", 2)];
512        let h = parse_http_annotations(&custom).unwrap().unwrap();
513        assert_eq!(h.status_codes, vec![200, 404]);
514    }
515
516    #[test]
517    fn parses_bearer_auth() {
518        let custom = vec![ann("http", "GET /a", 1), ann("http_auth", "bearer", 2)];
519        let h = parse_http_annotations(&custom).unwrap().unwrap();
520        assert_eq!(h.auth, Some(AuthRequirement::Bearer { format: None }));
521    }
522
523    #[test]
524    fn parses_bearer_with_format() {
525        let custom = vec![ann("http", "GET /a", 1), ann("http_auth", "bearer:jwt", 2)];
526        let h = parse_http_annotations(&custom).unwrap().unwrap();
527        assert_eq!(
528            h.auth,
529            Some(AuthRequirement::Bearer {
530                format: Some("jwt".to_string()),
531            })
532        );
533    }
534
535    #[test]
536    fn parses_api_key_auth() {
537        let custom = vec![
538            ann("http", "GET /a", 1),
539            ann("http_auth", "api_key:header:X-API-Key", 2),
540        ];
541        let h = parse_http_annotations(&custom).unwrap().unwrap();
542        assert_eq!(
543            h.auth,
544            Some(AuthRequirement::ApiKey {
545                location: ApiKeyLocation::Header,
546                name: "X-API-Key".to_string(),
547            })
548        );
549    }
550
551    #[test]
552    fn parses_none_auth() {
553        let custom = vec![ann("http", "GET /a", 1), ann("http_auth", "none", 2)];
554        let h = parse_http_annotations(&custom).unwrap().unwrap();
555        assert_eq!(h.auth, Some(AuthRequirement::None));
556    }
557
558    #[test]
559    fn rejects_unknown_auth_scheme() {
560        let custom = vec![ann("http", "GET /a", 1), ann("http_auth", "oauth2:scopes", 7)];
561        assert!(matches!(
562            parse_http_annotations(&custom).unwrap_err(),
563            AnnotationParseError::MalformedHttpAuth { line: 7, .. }
564        ));
565    }
566
567    #[test]
568    fn parses_tags_and_summary() {
569        let custom = vec![
570            ann("http", "GET /a", 1),
571            ann("http_tags", "users, admin ", 2),
572            ann("http_summary", "List users", 3),
573            ann("http_description", "Returns every user", 4),
574        ];
575        let h = parse_http_annotations(&custom).unwrap().unwrap();
576        assert_eq!(h.tags, vec!["users", "admin"]);
577        assert_eq!(h.summary.as_deref(), Some("List users"));
578        assert_eq!(h.description.as_deref(), Some("Returns every user"));
579    }
580
581    #[test]
582    fn ignores_unrelated_annotations() {
583        let custom = vec![
584            ann("http", "GET /a", 1),
585            ann("gql_field", "user.email", 2),
586            ann("queue", "background", 3),
587        ];
588        let h = parse_http_annotations(&custom).unwrap().unwrap();
589        assert_eq!(h.method, HttpMethod::Get);
590    }
591
592    #[test]
593    fn default_status_one_get() {
594        assert_eq!(default_status_for(&QueryCommand::One, HttpMethod::Get).unwrap(), 200);
595    }
596
597    #[test]
598    fn default_status_exec_post() {
599        assert_eq!(default_status_for(&QueryCommand::Exec, HttpMethod::Post).unwrap(), 204);
600    }
601
602    #[test]
603    fn default_status_exec_rows_put() {
604        assert_eq!(
605            default_status_for(&QueryCommand::ExecRows, HttpMethod::Put).unwrap(),
606            200
607        );
608    }
609
610    #[test]
611    fn rejects_batch_command() {
612        assert!(matches!(
613            default_status_for(&QueryCommand::Batch, HttpMethod::Get),
614            Err(AnnotationParseError::IncompatibleCommand { .. })
615        ));
616    }
617
618    #[test]
619    fn rejects_exec_result_command() {
620        assert!(matches!(
621            default_status_for(&QueryCommand::ExecResult, HttpMethod::Post),
622            Err(AnnotationParseError::IncompatibleCommand { .. })
623        ));
624    }
625
626    #[test]
627    fn rejects_one_with_post() {
628        assert!(matches!(
629            default_status_for(&QueryCommand::One, HttpMethod::Post),
630            Err(AnnotationParseError::MethodCommandMismatch { .. })
631        ));
632    }
633
634    #[test]
635    fn rejects_exec_with_get() {
636        assert!(matches!(
637            default_status_for(&QueryCommand::Exec, HttpMethod::Get),
638            Err(AnnotationParseError::MethodCommandMismatch { .. })
639        ));
640    }
641}