1use 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#[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#[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#[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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134pub struct HttpAnnotations {
135 pub method: HttpMethod,
136 pub path: String,
139 pub param_bindings: BTreeMap<String, HttpParamBinding>,
143 pub request_body_name: Option<String>,
145 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
154pub 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 _ => {}
246 }
247 }
248
249 let Some((_, method, path)) = http else {
250 return Ok(None);
251 };
252
253 Ok(Some(HttpAnnotations {
254 method,
255 path,
256 param_bindings,
257 request_body_name,
258 status_codes,
259 auth,
260 tags,
261 summary,
262 description,
263 }))
264}
265
266pub fn default_status_for(command: &QueryCommand, method: HttpMethod) -> Result<u16, AnnotationParseError> {
270 let (allowed, default): (&[HttpMethod], u16) = match command {
271 QueryCommand::One | QueryCommand::Opt | QueryCommand::Many | QueryCommand::Grouped => (&[HttpMethod::Get], 200),
272 QueryCommand::Exec => (
273 &[HttpMethod::Post, HttpMethod::Put, HttpMethod::Patch, HttpMethod::Delete],
274 204,
275 ),
276 QueryCommand::ExecRows => (
277 &[HttpMethod::Post, HttpMethod::Put, HttpMethod::Patch, HttpMethod::Delete],
278 200,
279 ),
280 QueryCommand::ExecResult | QueryCommand::Batch => {
281 return Err(AnnotationParseError::IncompatibleCommand {
282 command: command.to_string(),
283 });
284 }
285 };
286
287 if !allowed.contains(&method) {
288 return Err(AnnotationParseError::MethodCommandMismatch {
289 command: command.to_string(),
290 expected_methods: allowed.iter().map(|m| m.as_str()).collect(),
291 actual_method: method.as_str().to_string(),
292 });
293 }
294 Ok(default)
295}
296
297pub fn parse_for_query(query: &AnalyzedQuery) -> Result<Option<(HttpAnnotations, u16)>, AnnotationParseError> {
301 let Some(http) = parse_http_annotations(&query.custom)? else {
302 return Ok(None);
303 };
304 let default_status = default_status_for(&query.command, http.method)?;
305 Ok(Some((http, default_status)))
306}
307
308fn parse_binding(s: &str) -> Option<HttpParamBinding> {
309 match s.to_ascii_lowercase().as_str() {
310 "path" => Some(HttpParamBinding::Path),
311 "query" => Some(HttpParamBinding::Query),
312 "body" => Some(HttpParamBinding::Body),
313 "header" => Some(HttpParamBinding::Header),
314 _ => None,
315 }
316}
317
318fn parse_auth(value: &str, line: usize) -> Result<AuthRequirement, AnnotationParseError> {
319 let trimmed = value.trim();
320 if trimmed.eq_ignore_ascii_case("none") {
321 return Ok(AuthRequirement::None);
322 }
323 if let Some(rest) = trimmed
324 .strip_prefix("bearer")
325 .or_else(|| trimmed.strip_prefix("Bearer"))
326 {
327 let rest = rest.trim();
328 if rest.is_empty() {
329 return Ok(AuthRequirement::Bearer { format: None });
330 }
331 if let Some(format) = rest.strip_prefix(':') {
332 let format = format.trim();
333 if format.is_empty() {
334 return Ok(AuthRequirement::Bearer { format: None });
335 }
336 return Ok(AuthRequirement::Bearer {
337 format: Some(format.to_string()),
338 });
339 }
340 return Err(AnnotationParseError::MalformedHttpAuth {
341 line,
342 value: value.to_string(),
343 });
344 }
345 if let Some(rest) = trimmed
346 .strip_prefix("api_key")
347 .or_else(|| trimmed.strip_prefix("apikey"))
348 {
349 let rest = rest
350 .strip_prefix(':')
351 .ok_or_else(|| AnnotationParseError::MalformedHttpAuth {
352 line,
353 value: value.to_string(),
354 })?;
355 let (location_raw, name) = rest
356 .split_once(':')
357 .ok_or_else(|| AnnotationParseError::MalformedHttpAuth {
358 line,
359 value: value.to_string(),
360 })?;
361 let location = match location_raw.trim().to_ascii_lowercase().as_str() {
362 "header" => ApiKeyLocation::Header,
363 "query" => ApiKeyLocation::Query,
364 "cookie" => ApiKeyLocation::Cookie,
365 other => {
366 return Err(AnnotationParseError::UnknownApiKeyLocation {
367 line,
368 location: other.to_string(),
369 });
370 }
371 };
372 return Ok(AuthRequirement::ApiKey {
373 location,
374 name: name.trim().to_string(),
375 });
376 }
377 Err(AnnotationParseError::MalformedHttpAuth {
378 line,
379 value: value.to_string(),
380 })
381}
382
383fn normalize_path(raw: &str) -> String {
387 let mut out = String::with_capacity(raw.len());
388 let bytes = raw.as_bytes();
389 let mut i = 0;
390 while i < bytes.len() {
391 let b = bytes[i];
392 if b == b':' && i + 1 < bytes.len() && is_ident_start(bytes[i + 1]) {
393 out.push('{');
394 i += 1;
395 while i < bytes.len() && is_ident_continue(bytes[i]) {
396 out.push(bytes[i] as char);
397 i += 1;
398 }
399 out.push('}');
400 } else {
401 out.push(b as char);
402 i += 1;
403 }
404 }
405 out
406}
407
408const fn is_ident_start(b: u8) -> bool {
409 b.is_ascii_alphabetic() || b == b'_'
410}
411
412const fn is_ident_continue(b: u8) -> bool {
413 b.is_ascii_alphanumeric() || b == b'_'
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419 use scythe_core::parser::CustomAnnotation;
420
421 fn ann(name: &str, value: &str, line: usize) -> CustomAnnotation {
422 CustomAnnotation {
423 name: name.to_string(),
424 value: value.to_string(),
425 line,
426 suggested_keyword: None,
427 }
428 }
429
430 #[test]
431 fn returns_none_when_no_http_directive() {
432 let custom = vec![ann("http_auth", "bearer", 1)];
433 assert_eq!(parse_http_annotations(&custom).unwrap(), None);
434 }
435
436 #[test]
437 fn parses_basic_get_route() {
438 let custom = vec![ann("http", "GET /users/{id}", 3)];
439 let h = parse_http_annotations(&custom).unwrap().unwrap();
440 assert_eq!(h.method, HttpMethod::Get);
441 assert_eq!(h.path, "/users/{id}");
442 }
443
444 #[test]
445 fn normalizes_colon_placeholders_to_braces() {
446 let custom = vec![ann("http", "GET /users/:id/orders/:order_id", 1)];
447 let h = parse_http_annotations(&custom).unwrap().unwrap();
448 assert_eq!(h.path, "/users/{id}/orders/{order_id}");
449 }
450
451 #[test]
452 fn leaves_brace_placeholders_unchanged() {
453 let custom = vec![ann("http", "GET /users/{id}/orders/{order_id}", 1)];
454 let h = parse_http_annotations(&custom).unwrap().unwrap();
455 assert_eq!(h.path, "/users/{id}/orders/{order_id}");
456 }
457
458 #[test]
459 fn rejects_duplicate_http_directives() {
460 let custom = vec![ann("http", "GET /a", 1), ann("http", "GET /b", 2)];
461 assert!(matches!(
462 parse_http_annotations(&custom).unwrap_err(),
463 AnnotationParseError::DuplicateHttp { line: 2 }
464 ));
465 }
466
467 #[test]
468 fn rejects_unknown_method() {
469 let custom = vec![ann("http", "FETCH /users", 4)];
470 assert!(matches!(
471 parse_http_annotations(&custom).unwrap_err(),
472 AnnotationParseError::UnknownMethod { line: 4, .. }
473 ));
474 }
475
476 #[test]
477 fn parses_param_bindings() {
478 let custom = vec![
479 ann("http", "POST /users", 1),
480 ann("http_param", "id path", 2),
481 ann("http_param", "email body", 3),
482 ann("http_param", "limit query", 4),
483 ];
484 let h = parse_http_annotations(&custom).unwrap().unwrap();
485 assert_eq!(h.param_bindings.get("id"), Some(&HttpParamBinding::Path));
486 assert_eq!(h.param_bindings.get("email"), Some(&HttpParamBinding::Body));
487 assert_eq!(h.param_bindings.get("limit"), Some(&HttpParamBinding::Query));
488 }
489
490 #[test]
491 fn rejects_unknown_binding() {
492 let custom = vec![ann("http", "POST /x", 1), ann("http_param", "id foo", 5)];
493 assert!(matches!(
494 parse_http_annotations(&custom).unwrap_err(),
495 AnnotationParseError::UnknownBinding { line: 5, .. }
496 ));
497 }
498
499 #[test]
500 fn parses_status_codes() {
501 let custom = vec![ann("http", "GET /a", 1), ann("http_status", "200, 404", 2)];
502 let h = parse_http_annotations(&custom).unwrap().unwrap();
503 assert_eq!(h.status_codes, vec![200, 404]);
504 }
505
506 #[test]
507 fn parses_bearer_auth() {
508 let custom = vec![ann("http", "GET /a", 1), ann("http_auth", "bearer", 2)];
509 let h = parse_http_annotations(&custom).unwrap().unwrap();
510 assert_eq!(h.auth, Some(AuthRequirement::Bearer { format: None }));
511 }
512
513 #[test]
514 fn parses_bearer_with_format() {
515 let custom = vec![ann("http", "GET /a", 1), ann("http_auth", "bearer:jwt", 2)];
516 let h = parse_http_annotations(&custom).unwrap().unwrap();
517 assert_eq!(
518 h.auth,
519 Some(AuthRequirement::Bearer {
520 format: Some("jwt".to_string()),
521 })
522 );
523 }
524
525 #[test]
526 fn parses_api_key_auth() {
527 let custom = vec![
528 ann("http", "GET /a", 1),
529 ann("http_auth", "api_key:header:X-API-Key", 2),
530 ];
531 let h = parse_http_annotations(&custom).unwrap().unwrap();
532 assert_eq!(
533 h.auth,
534 Some(AuthRequirement::ApiKey {
535 location: ApiKeyLocation::Header,
536 name: "X-API-Key".to_string(),
537 })
538 );
539 }
540
541 #[test]
542 fn parses_none_auth() {
543 let custom = vec![ann("http", "GET /a", 1), ann("http_auth", "none", 2)];
544 let h = parse_http_annotations(&custom).unwrap().unwrap();
545 assert_eq!(h.auth, Some(AuthRequirement::None));
546 }
547
548 #[test]
549 fn rejects_unknown_auth_scheme() {
550 let custom = vec![ann("http", "GET /a", 1), ann("http_auth", "oauth2:scopes", 7)];
551 assert!(matches!(
552 parse_http_annotations(&custom).unwrap_err(),
553 AnnotationParseError::MalformedHttpAuth { line: 7, .. }
554 ));
555 }
556
557 #[test]
558 fn parses_tags_and_summary() {
559 let custom = vec![
560 ann("http", "GET /a", 1),
561 ann("http_tags", "users, admin ", 2),
562 ann("http_summary", "List users", 3),
563 ann("http_description", "Returns every user", 4),
564 ];
565 let h = parse_http_annotations(&custom).unwrap().unwrap();
566 assert_eq!(h.tags, vec!["users", "admin"]);
567 assert_eq!(h.summary.as_deref(), Some("List users"));
568 assert_eq!(h.description.as_deref(), Some("Returns every user"));
569 }
570
571 #[test]
572 fn ignores_unrelated_annotations() {
573 let custom = vec![
574 ann("http", "GET /a", 1),
575 ann("gql_field", "user.email", 2),
576 ann("queue", "background", 3),
577 ];
578 let h = parse_http_annotations(&custom).unwrap().unwrap();
579 assert_eq!(h.method, HttpMethod::Get);
580 }
581
582 #[test]
583 fn default_status_one_get() {
584 assert_eq!(default_status_for(&QueryCommand::One, HttpMethod::Get).unwrap(), 200);
585 }
586
587 #[test]
588 fn default_status_exec_post() {
589 assert_eq!(default_status_for(&QueryCommand::Exec, HttpMethod::Post).unwrap(), 204);
590 }
591
592 #[test]
593 fn default_status_exec_rows_put() {
594 assert_eq!(
595 default_status_for(&QueryCommand::ExecRows, HttpMethod::Put).unwrap(),
596 200
597 );
598 }
599
600 #[test]
601 fn rejects_batch_command() {
602 assert!(matches!(
603 default_status_for(&QueryCommand::Batch, HttpMethod::Get),
604 Err(AnnotationParseError::IncompatibleCommand { .. })
605 ));
606 }
607
608 #[test]
609 fn rejects_exec_result_command() {
610 assert!(matches!(
611 default_status_for(&QueryCommand::ExecResult, HttpMethod::Post),
612 Err(AnnotationParseError::IncompatibleCommand { .. })
613 ));
614 }
615
616 #[test]
617 fn rejects_one_with_post() {
618 assert!(matches!(
619 default_status_for(&QueryCommand::One, HttpMethod::Post),
620 Err(AnnotationParseError::MethodCommandMismatch { .. })
621 ));
622 }
623
624 #[test]
625 fn rejects_exec_with_get() {
626 assert!(matches!(
627 default_status_for(&QueryCommand::Exec, HttpMethod::Get),
628 Err(AnnotationParseError::MethodCommandMismatch { .. })
629 ));
630 }
631}