Skip to main content

spikard_codegen/sql/
annotations.rs

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