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