1#![allow(
7 clippy::missing_errors_doc,
8 clippy::missing_panics_doc,
9 clippy::must_use_candidate,
10 clippy::doc_markdown,
11 clippy::too_long_first_doc_paragraph,
12 clippy::module_name_repetitions,
13 clippy::too_many_lines,
14 clippy::similar_names
15)]
16pub mod annotations;
34pub mod neutral_to_json_schema;
35pub mod openapi;
36pub mod route;
37pub mod sidecar;
38
39pub use annotations::{
40 AnnotationParseError, ApiKeyLocation, AuthRequirement, HttpAnnotations, HttpMethod, HttpParamBinding,
41 parse_http_annotations,
42};
43pub use neutral_to_json_schema::{BuildOptions, DecimalMode, neutral_to_json_schema};
44pub use openapi::{OpenApiInfo, openapi_from_routes};
45pub use route::{RouteBuildError, SqlRoute, route_from_query};
46pub use sidecar::{Sidecar, SidecarEntry, SidecarParam};
47
48use scythe_core::analyzer::AnalyzedQuery;
49use scythe_core::catalog::Catalog;
50use serde_json::Value;
51
52#[derive(Debug, Clone)]
54pub struct HandlerSet {
55 pub routes: Vec<Value>,
57 pub sql_routes: Vec<SqlRoute>,
61 pub openapi: Value,
63 pub sidecar: Sidecar,
65}
66
67pub fn build_handler_set(
77 catalog: &Catalog,
78 queries: &[AnalyzedQuery],
79 info: &OpenApiInfo,
80 opts: &BuildOptions,
81 languages: &[LanguageBackend<'_>],
82) -> Result<HandlerSet, RouteBuildError> {
83 let mut sql_routes = Vec::new();
84 let mut routes = Vec::new();
85
86 for query in queries {
87 let Some(route) = route_from_query(query, catalog, opts)? else {
88 continue;
89 };
90 routes.push(route.metadata.clone());
91 sql_routes.push(route);
92 }
93
94 let openapi = openapi_from_routes(&sql_routes, info);
95
96 let mut sidecar = Sidecar::new();
97 for backend in languages {
98 for (route, query) in sql_routes.iter().zip(matching_queries(queries, &sql_routes)) {
99 let scythe_fn = (backend.scythe_fn_for)(&query.name);
100 let entry = sidecar::build_sidecar_entry(
101 query,
102 &route.param_locations,
103 backend.scythe_module,
104 &scythe_fn,
105 backend.is_async,
106 |neutral, nullable| (backend.lang_type_for)(neutral, nullable),
107 );
108 sidecar.insert(backend.name, &route.operation_id, entry);
109 }
110 }
111
112 Ok(HandlerSet {
113 routes,
114 sql_routes,
115 openapi,
116 sidecar,
117 })
118}
119
120pub struct LanguageBackend<'a> {
124 pub name: &'a str,
125 pub scythe_module: &'a str,
126 pub is_async: bool,
127 pub scythe_fn_for: &'a dyn Fn(&str) -> String,
128 pub lang_type_for: &'a dyn Fn(&str, bool) -> String,
129}
130
131fn matching_queries<'a>(queries: &'a [AnalyzedQuery], routes: &[SqlRoute]) -> Vec<&'a AnalyzedQuery> {
132 routes
133 .iter()
134 .filter_map(|r| queries.iter().find(|q| q.name == r.operation_id))
135 .collect()
136}
137
138#[cfg(test)]
139mod orchestrator_tests {
140 use super::*;
141 use scythe_core::analyzer::{AnalyzedColumn, AnalyzedParam, AnalyzedQuery};
142 use scythe_core::parser::{CustomAnnotation, QueryCommand};
143
144 fn empty_catalog() -> Catalog {
145 Catalog::from_ddl(&[]).unwrap()
146 }
147
148 fn snake_for(name: &str) -> String {
149 let mut out = String::new();
150 let mut prev_lower = false;
151 for c in name.chars() {
152 if c.is_ascii_uppercase() {
153 if prev_lower {
154 out.push('_');
155 }
156 out.push(c.to_ascii_lowercase());
157 prev_lower = false;
158 } else {
159 out.push(c);
160 prev_lower = c.is_ascii_lowercase() || c.is_ascii_digit();
161 }
162 }
163 out
164 }
165
166 fn py_type(neutral: &str, nullable: bool) -> String {
167 let base = match neutral {
168 "int32" | "int64" | "int16" => "int",
169 "string" => "str",
170 "bool" => "bool",
171 _ => "Any",
172 };
173 if nullable {
174 format!("{base} | None")
175 } else {
176 base.to_string()
177 }
178 }
179
180 fn get_user() -> AnalyzedQuery {
181 AnalyzedQuery {
182 name: "GetUser".to_string(),
183 command: QueryCommand::One,
184 sql: "SELECT id FROM users WHERE id = $1".into(),
185 columns: vec![AnalyzedColumn {
186 name: "id".into(),
187 neutral_type: "int64".into(),
188 nullable: false,
189 }],
190 params: vec![AnalyzedParam {
191 name: "id".into(),
192 neutral_type: "int64".into(),
193 nullable: false,
194 position: 1,
195 }],
196 deprecated: None,
197 source_table: Some("users".into()),
198 composites: vec![],
199 enums: vec![],
200 optional_params: vec![],
201 group_by: None,
202 custom: vec![CustomAnnotation {
203 name: "http".into(),
204 value: "GET /users/{id}".into(),
205 line: 1,
206 }],
207 }
208 }
209
210 fn no_http() -> AnalyzedQuery {
211 AnalyzedQuery {
212 name: "InternalQuery".to_string(),
213 command: QueryCommand::One,
214 sql: "SELECT 1".into(),
215 columns: vec![],
216 params: vec![],
217 deprecated: None,
218 source_table: None,
219 composites: vec![],
220 enums: vec![],
221 optional_params: vec![],
222 group_by: None,
223 custom: vec![],
224 }
225 }
226
227 #[test]
228 fn skips_queries_without_http_directive() {
229 let queries = vec![get_user(), no_http()];
230 let set = build_handler_set(
231 &empty_catalog(),
232 &queries,
233 &OpenApiInfo::new("t", "0.1"),
234 &BuildOptions::default(),
235 &[],
236 )
237 .unwrap();
238 assert_eq!(set.routes.len(), 1);
239 assert_eq!(set.sql_routes.len(), 1);
240 assert_eq!(set.sql_routes[0].operation_id, "GetUser");
241 }
242
243 #[test]
244 fn populates_sidecar_per_language() {
245 let queries = vec![get_user()];
246 let snake = |s: &str| snake_for(s);
247 let set = build_handler_set(
248 &empty_catalog(),
249 &queries,
250 &OpenApiInfo::new("t", "0.1"),
251 &BuildOptions::default(),
252 &[LanguageBackend {
253 name: "python",
254 scythe_module: "queries",
255 is_async: true,
256 scythe_fn_for: &snake,
257 lang_type_for: &py_type,
258 }],
259 )
260 .unwrap();
261 let entry = set.sidecar.entry_for("python", "GetUser").unwrap();
262 assert_eq!(entry.scythe_module, "queries");
263 assert_eq!(entry.scythe_fn, "get_user");
264 assert!(entry.is_async);
265 }
266
267 #[test]
268 fn openapi_emitted_in_set() {
269 let queries = vec![get_user()];
270 let set = build_handler_set(
271 &empty_catalog(),
272 &queries,
273 &OpenApiInfo::new("t", "0.1"),
274 &BuildOptions::default(),
275 &[],
276 )
277 .unwrap();
278 assert_eq!(set.openapi["openapi"], "3.1.0");
279 assert!(set.openapi["paths"]["/users/{id}"]["get"].is_object());
280 }
281}