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