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;
13
14use scythe_core::analyzer::AnalyzedQuery;
15use scythe_core::catalog::Catalog;
16use scythe_core::parser::QueryCommand;
17use serde_json::{Map, Value, json};
18use thiserror::Error;
19
20use super::annotations::{
21 AnnotationParseError, HttpAnnotations, HttpMethod, HttpParamBinding, default_status_for, parse_http_annotations,
22};
23use super::neutral_to_json_schema::{BuildOptions, NeutralTypeError, json_schema_for};
24
25#[derive(Debug, Error)]
26pub enum RouteBuildError {
27 #[error("annotation error: {0}")]
28 Annotation(#[from] AnnotationParseError),
29
30 #[error("neutral type error: {0}")]
31 NeutralType(#[from] NeutralTypeError),
32}
33
34#[derive(Debug, Clone)]
39pub struct SqlRoute {
40 pub metadata: Value,
44 pub http: HttpAnnotations,
47 pub param_locations: BTreeMap<String, HttpParamBinding>,
50 pub default_status: u16,
53 pub body_bundle_name: String,
55 pub operation_id: String,
57 pub handler_name: String,
59}
60
61pub fn route_from_query(
65 query: &AnalyzedQuery,
66 catalog: &Catalog,
67 opts: &BuildOptions,
68) -> Result<Option<SqlRoute>, RouteBuildError> {
69 let Some(http) = parse_http_annotations(&query.custom)? else {
70 return Ok(None);
71 };
72 let default_status = default_status_for(&query.command, http.method)?;
73
74 let param_locations = bin_param_locations(query, &http);
75 let body_bundle_name = http.request_body_name.clone().unwrap_or_else(|| "payload".to_string());
76
77 let parameter_schema = build_parameter_schema(query, ¶m_locations, catalog, opts)?;
78 let request_schema = build_request_schema(query, ¶m_locations, &body_bundle_name, catalog, opts)?;
79 let response_schema = build_response_schema(query, catalog, opts)?;
80
81 let handler_name = format!("handle_{}", to_snake_case(&query.name));
82 let operation_id = query.name.clone();
83
84 let body_param_name = single_body_param(query, ¶m_locations).map(str::to_string);
85 let expects_json_body = matches!(http.method, HttpMethod::Post | HttpMethod::Put | HttpMethod::Patch)
86 && param_locations.values().any(|v| *v == HttpParamBinding::Body);
87
88 let mut metadata = Map::new();
89 metadata.insert("method".into(), json!(http.method.as_str()));
90 metadata.insert("path".into(), json!(&http.path));
91 metadata.insert("handler_name".into(), json!(&handler_name));
92 metadata.insert("request_schema".into(), request_schema);
93 metadata.insert("response_schema".into(), response_schema);
94 metadata.insert("parameter_schema".into(), parameter_schema);
95 metadata.insert("is_async".into(), json!(true));
96 metadata.insert("expects_json_body".into(), json!(expects_json_body));
97 if let Some(body_name) = body_param_name {
98 metadata.insert("body_param_name".into(), json!(body_name));
99 }
100
101 Ok(Some(SqlRoute {
102 metadata: Value::Object(metadata),
103 http,
104 param_locations,
105 default_status,
106 body_bundle_name,
107 operation_id,
108 handler_name,
109 }))
110}
111
112pub fn bin_param_locations(query: &AnalyzedQuery, http: &HttpAnnotations) -> BTreeMap<String, HttpParamBinding> {
119 let path_segments: Vec<&str> = extract_path_params(&http.path);
120 let mut bindings = BTreeMap::new();
121 for p in &query.params {
122 if let Some(explicit) = http.param_bindings.get(&p.name) {
123 bindings.insert(p.name.clone(), *explicit);
124 continue;
125 }
126 if path_segments.iter().any(|s| *s == p.name) {
127 bindings.insert(p.name.clone(), HttpParamBinding::Path);
128 continue;
129 }
130 let inferred = match http.method {
131 HttpMethod::Get | HttpMethod::Delete | HttpMethod::Head | HttpMethod::Options => HttpParamBinding::Query,
132 HttpMethod::Post | HttpMethod::Put | HttpMethod::Patch => HttpParamBinding::Body,
133 };
134 bindings.insert(p.name.clone(), inferred);
135 }
136 bindings
137}
138
139fn extract_path_params(path: &str) -> Vec<&str> {
140 let mut out = Vec::new();
141 let bytes = path.as_bytes();
142 let mut i = 0;
143 while i < bytes.len() {
144 if bytes[i] == b'{' {
145 let start = i + 1;
146 while i < bytes.len() && bytes[i] != b'}' {
147 i += 1;
148 }
149 if i < bytes.len() && bytes[i] == b'}' {
150 out.push(&path[start..i]);
151 }
152 }
153 i += 1;
154 }
155 out
156}
157
158fn single_body_param<'a>(query: &'a AnalyzedQuery, locations: &BTreeMap<String, HttpParamBinding>) -> Option<&'a str> {
159 let body_names: Vec<&str> = query
160 .params
161 .iter()
162 .filter(|p| locations.get(&p.name) == Some(&HttpParamBinding::Body))
163 .map(|p| p.name.as_str())
164 .collect();
165 if body_names.len() == 1 {
166 Some(body_names[0])
167 } else {
168 None
169 }
170}
171
172fn build_parameter_schema(
173 query: &AnalyzedQuery,
174 locations: &BTreeMap<String, HttpParamBinding>,
175 catalog: &Catalog,
176 opts: &BuildOptions,
177) -> Result<Value, RouteBuildError> {
178 let mut props = Map::new();
179 let mut required: Vec<String> = Vec::new();
180 let optional_set: std::collections::HashSet<&str> = query.optional_params.iter().map(String::as_str).collect();
181 for p in &query.params {
182 let loc = locations.get(&p.name).copied().unwrap_or(HttpParamBinding::Body);
183 if !matches!(loc, HttpParamBinding::Path | HttpParamBinding::Query) {
184 continue;
185 }
186 let schema = json_schema_for(&p.neutral_type, p.nullable, &query.enums, catalog, opts)?;
187 props.insert(p.name.clone(), schema);
188 let is_required = matches!(loc, HttpParamBinding::Path) || !optional_set.contains(p.name.as_str());
189 if is_required {
190 required.push(p.name.clone());
191 }
192 }
193 if props.is_empty() {
194 return Ok(Value::Null);
195 }
196 let mut obj = Map::new();
197 obj.insert("type".into(), json!("object"));
198 obj.insert("properties".into(), Value::Object(props));
199 if !required.is_empty() {
200 obj.insert("required".into(), json!(required));
201 }
202 Ok(Value::Object(obj))
203}
204
205fn build_request_schema(
206 query: &AnalyzedQuery,
207 locations: &BTreeMap<String, HttpParamBinding>,
208 _bundle_name: &str,
209 catalog: &Catalog,
210 opts: &BuildOptions,
211) -> Result<Value, RouteBuildError> {
212 let optional_set: std::collections::HashSet<&str> = query.optional_params.iter().map(String::as_str).collect();
213 let mut props = Map::new();
214 let mut required: Vec<String> = Vec::new();
215 for p in &query.params {
216 if locations.get(&p.name) != Some(&HttpParamBinding::Body) {
217 continue;
218 }
219 let schema = json_schema_for(&p.neutral_type, p.nullable, &query.enums, catalog, opts)?;
220 props.insert(p.name.clone(), schema);
221 if !optional_set.contains(p.name.as_str()) {
222 required.push(p.name.clone());
223 }
224 }
225 if props.is_empty() {
226 return Ok(Value::Null);
227 }
228 let mut obj = Map::new();
229 obj.insert("type".into(), json!("object"));
230 obj.insert("properties".into(), Value::Object(props));
231 if !required.is_empty() {
232 obj.insert("required".into(), json!(required));
233 }
234 Ok(Value::Object(obj))
235}
236
237fn build_response_schema(
238 query: &AnalyzedQuery,
239 catalog: &Catalog,
240 opts: &BuildOptions,
241) -> Result<Value, RouteBuildError> {
242 match query.command {
243 QueryCommand::Exec | QueryCommand::ExecResult | QueryCommand::Batch => Ok(Value::Null),
244 QueryCommand::ExecRows => Ok(json!({
245 "type": "object",
246 "properties": { "rows": { "type": "integer", "format": "int64" } },
247 "required": ["rows"],
248 })),
249 QueryCommand::One | QueryCommand::Opt => {
250 let row = row_object_schema(query, catalog, opts)?;
251 if matches!(query.command, QueryCommand::Opt) {
252 Ok(json!({ "oneOf": [row, { "type": "null" }] }))
253 } else {
254 Ok(row)
255 }
256 }
257 QueryCommand::Many => {
258 let row = row_object_schema(query, catalog, opts)?;
259 Ok(json!({ "type": "array", "items": row }))
260 }
261 QueryCommand::Grouped => {
262 let row = row_object_schema(query, catalog, opts)?;
265 Ok(json!({ "type": "array", "items": row }))
266 }
267 }
268}
269
270fn row_object_schema(query: &AnalyzedQuery, catalog: &Catalog, opts: &BuildOptions) -> Result<Value, RouteBuildError> {
271 let mut props = Map::new();
272 let mut required: Vec<String> = Vec::new();
273 for col in &query.columns {
274 let schema = json_schema_for(&col.neutral_type, col.nullable, &query.enums, catalog, opts)?;
275 props.insert(col.name.clone(), schema);
276 required.push(col.name.clone());
277 }
278 let mut obj = Map::new();
279 obj.insert("type".into(), json!("object"));
280 obj.insert("properties".into(), Value::Object(props));
281 if !required.is_empty() {
282 obj.insert("required".into(), json!(required));
283 }
284 Ok(Value::Object(obj))
285}
286
287fn to_snake_case(s: &str) -> String {
288 let mut out = String::with_capacity(s.len() + 4);
289 let mut prev_lower = false;
290 for c in s.chars() {
291 if c.is_ascii_uppercase() {
292 if prev_lower {
293 out.push('_');
294 }
295 out.push(c.to_ascii_lowercase());
296 prev_lower = false;
297 } else {
298 out.push(c);
299 prev_lower = c.is_ascii_lowercase() || c.is_ascii_digit();
300 }
301 }
302 out
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308 use scythe_core::analyzer::{AnalyzedColumn, AnalyzedParam, AnalyzedQuery};
309 use scythe_core::parser::{CustomAnnotation, QueryCommand};
310
311 fn empty_catalog() -> Catalog {
312 Catalog::from_ddl(&[]).unwrap()
313 }
314
315 fn get_user_query() -> AnalyzedQuery {
316 AnalyzedQuery {
317 name: "GetUser".to_string(),
318 command: QueryCommand::One,
319 sql: "SELECT id, email, name FROM users WHERE id = $1".to_string(),
320 columns: vec![
321 AnalyzedColumn {
322 name: "id".to_string(),
323 neutral_type: "int64".to_string(),
324 nullable: false,
325 },
326 AnalyzedColumn {
327 name: "email".to_string(),
328 neutral_type: "string".to_string(),
329 nullable: false,
330 },
331 AnalyzedColumn {
332 name: "name".to_string(),
333 neutral_type: "string".to_string(),
334 nullable: true,
335 },
336 ],
337 params: vec![AnalyzedParam {
338 name: "id".to_string(),
339 neutral_type: "int64".to_string(),
340 nullable: false,
341 position: 1,
342 }],
343 deprecated: None,
344 source_table: Some("users".to_string()),
345 composites: vec![],
346 enums: vec![],
347 optional_params: vec![],
348 group_by: None,
349 custom: vec![
350 CustomAnnotation {
351 name: "http".into(),
352 value: "GET /users/{id}".into(),
353 line: 3,
354 },
355 CustomAnnotation {
356 name: "http_auth".into(),
357 value: "bearer:jwt".into(),
358 line: 4,
359 },
360 CustomAnnotation {
361 name: "http_status".into(),
362 value: "200,404".into(),
363 line: 5,
364 },
365 ],
366 }
367 }
368
369 fn create_user_query() -> AnalyzedQuery {
370 AnalyzedQuery {
371 name: "CreateUser".to_string(),
372 command: QueryCommand::ExecRows,
373 sql: "INSERT INTO users (email, name) VALUES ($1, $2)".to_string(),
374 columns: vec![],
375 params: vec![
376 AnalyzedParam {
377 name: "email".to_string(),
378 neutral_type: "string".to_string(),
379 nullable: false,
380 position: 1,
381 },
382 AnalyzedParam {
383 name: "name".to_string(),
384 neutral_type: "string".to_string(),
385 nullable: true,
386 position: 2,
387 },
388 ],
389 deprecated: None,
390 source_table: None,
391 composites: vec![],
392 enums: vec![],
393 optional_params: vec![],
394 group_by: None,
395 custom: vec![
396 CustomAnnotation {
397 name: "http".into(),
398 value: "POST /users".into(),
399 line: 1,
400 },
401 CustomAnnotation {
402 name: "http_status".into(),
403 value: "201".into(),
404 line: 2,
405 },
406 ],
407 }
408 }
409
410 fn list_users_query() -> AnalyzedQuery {
411 AnalyzedQuery {
412 name: "ListUsers".to_string(),
413 command: QueryCommand::Many,
414 sql: "SELECT id, email FROM users LIMIT $1 OFFSET $2".to_string(),
415 columns: vec![
416 AnalyzedColumn {
417 name: "id".to_string(),
418 neutral_type: "int64".to_string(),
419 nullable: false,
420 },
421 AnalyzedColumn {
422 name: "email".to_string(),
423 neutral_type: "string".to_string(),
424 nullable: false,
425 },
426 ],
427 params: vec![
428 AnalyzedParam {
429 name: "limit".to_string(),
430 neutral_type: "int32".to_string(),
431 nullable: true,
432 position: 1,
433 },
434 AnalyzedParam {
435 name: "offset".to_string(),
436 neutral_type: "int32".to_string(),
437 nullable: true,
438 position: 2,
439 },
440 ],
441 deprecated: None,
442 source_table: Some("users".to_string()),
443 composites: vec![],
444 enums: vec![],
445 optional_params: vec!["limit".to_string(), "offset".to_string()],
446 group_by: None,
447 custom: vec![CustomAnnotation {
448 name: "http".into(),
449 value: "GET /users".into(),
450 line: 1,
451 }],
452 }
453 }
454
455 #[test]
456 fn route_from_get_query_uses_get_method() {
457 let q = get_user_query();
458 let route = route_from_query(&q, &empty_catalog(), &BuildOptions::default())
459 .unwrap()
460 .unwrap();
461 assert_eq!(route.metadata["method"], "GET");
462 assert_eq!(route.metadata["path"], "/users/{id}");
463 assert_eq!(route.metadata["handler_name"], "handle_get_user");
464 assert_eq!(route.operation_id, "GetUser");
465 }
466
467 #[test]
468 fn handler_name_distinct_from_scythe_fn() {
469 let route = route_from_query(&get_user_query(), &empty_catalog(), &BuildOptions::default())
472 .unwrap()
473 .unwrap();
474 assert_eq!(route.handler_name, "handle_get_user");
475 assert_ne!(route.handler_name, "get_user");
476 }
477
478 #[test]
479 fn path_param_bound_to_path() {
480 let route = route_from_query(&get_user_query(), &empty_catalog(), &BuildOptions::default())
481 .unwrap()
482 .unwrap();
483 assert_eq!(route.param_locations.get("id"), Some(&HttpParamBinding::Path));
484 }
485
486 #[test]
487 fn parameter_schema_carries_path_param_as_required() {
488 let route = route_from_query(&get_user_query(), &empty_catalog(), &BuildOptions::default())
489 .unwrap()
490 .unwrap();
491 let params = &route.metadata["parameter_schema"];
492 assert_eq!(params["type"], "object");
493 assert!(params["properties"]["id"].is_object());
494 assert_eq!(params["required"], json!(["id"]));
495 }
496
497 #[test]
498 fn list_query_params_become_query_and_optional() {
499 let route = route_from_query(&list_users_query(), &empty_catalog(), &BuildOptions::default())
500 .unwrap()
501 .unwrap();
502 assert_eq!(route.param_locations.get("limit"), Some(&HttpParamBinding::Query));
503 let params = &route.metadata["parameter_schema"];
504 assert!(params["properties"]["limit"].is_object());
506 assert!(params["required"].is_null() || !params["required"].as_array().unwrap().iter().any(|v| v == "limit"));
507 }
508
509 #[test]
510 fn post_query_params_become_body() {
511 let route = route_from_query(&create_user_query(), &empty_catalog(), &BuildOptions::default())
512 .unwrap()
513 .unwrap();
514 assert_eq!(route.param_locations.get("email"), Some(&HttpParamBinding::Body));
515 assert_eq!(route.metadata["method"], "POST");
516 let req = &route.metadata["request_schema"];
517 assert_eq!(req["type"], "object");
518 assert!(req["properties"]["email"].is_object());
519 assert!(req["properties"]["name"].is_object());
520 assert_eq!(req["required"], json!(["email", "name"]));
521 assert_eq!(route.metadata["expects_json_body"], true);
522 }
523
524 #[test]
525 fn one_query_response_is_object_with_required_columns() {
526 let route = route_from_query(&get_user_query(), &empty_catalog(), &BuildOptions::default())
527 .unwrap()
528 .unwrap();
529 let resp = &route.metadata["response_schema"];
530 assert_eq!(resp["type"], "object");
531 assert_eq!(resp["required"], json!(["id", "email", "name"]));
532 }
533
534 #[test]
535 fn many_query_response_is_array() {
536 let route = route_from_query(&list_users_query(), &empty_catalog(), &BuildOptions::default())
537 .unwrap()
538 .unwrap();
539 let resp = &route.metadata["response_schema"];
540 assert_eq!(resp["type"], "array");
541 assert_eq!(resp["items"]["type"], "object");
542 }
543
544 #[test]
545 fn exec_rows_response_is_rows_object() {
546 let route = route_from_query(&create_user_query(), &empty_catalog(), &BuildOptions::default())
547 .unwrap()
548 .unwrap();
549 let resp = &route.metadata["response_schema"];
550 assert_eq!(resp["type"], "object");
551 assert_eq!(resp["properties"]["rows"]["type"], "integer");
552 assert_eq!(resp["required"], json!(["rows"]));
553 }
554
555 #[test]
556 fn nullable_column_emits_oneof_null() {
557 let route = route_from_query(&get_user_query(), &empty_catalog(), &BuildOptions::default())
558 .unwrap()
559 .unwrap();
560 let resp = &route.metadata["response_schema"];
561 let name_schema = &resp["properties"]["name"];
562 assert!(name_schema["oneOf"].is_array());
563 }
564
565 #[test]
566 fn no_http_directive_returns_none() {
567 let mut q = get_user_query();
568 q.custom.clear();
569 let route = route_from_query(&q, &empty_catalog(), &BuildOptions::default()).unwrap();
570 assert!(route.is_none());
571 }
572
573 #[test]
574 fn batch_command_with_http_errors() {
575 let mut q = get_user_query();
576 q.command = QueryCommand::Batch;
577 let err = route_from_query(&q, &empty_catalog(), &BuildOptions::default()).unwrap_err();
578 assert!(matches!(
579 err,
580 RouteBuildError::Annotation(AnnotationParseError::IncompatibleCommand { .. })
581 ));
582 }
583
584 #[test]
585 fn snake_case_handles_pascal_case() {
586 assert_eq!(to_snake_case("GetUser"), "get_user");
587 assert_eq!(to_snake_case("ListActiveUsers"), "list_active_users");
588 assert_eq!(to_snake_case("CreateUser"), "create_user");
589 }
590
591 #[test]
592 fn default_status_matches_command() {
593 let route = route_from_query(&get_user_query(), &empty_catalog(), &BuildOptions::default())
594 .unwrap()
595 .unwrap();
596 assert_eq!(route.default_status, 200);
597 let route = route_from_query(&create_user_query(), &empty_catalog(), &BuildOptions::default())
598 .unwrap()
599 .unwrap();
600 assert_eq!(route.default_status, 200); }
602
603 #[test]
604 fn single_body_param_recorded_in_metadata() {
605 let mut q = create_user_query();
606 q.params.truncate(1); let route = route_from_query(&q, &empty_catalog(), &BuildOptions::default())
608 .unwrap()
609 .unwrap();
610 assert_eq!(route.metadata["body_param_name"], "email");
611 }
612}