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)]
10use 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#[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#[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#[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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
143pub struct HttpAnnotations {
144 pub method: HttpMethod,
145 pub path: String,
148 pub param_bindings: BTreeMap<String, HttpParamBinding>,
152 pub request_body_name: Option<String>,
154 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
163pub 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 _ => {}
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
277pub 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
308pub 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
394fn 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}