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 {
177 name: "GetUser".to_string(),
178 command: QueryCommand::One,
179 sql: "SELECT id FROM users WHERE id = $1".into(),
180 columns: vec![AnalyzedColumn {
181 name: "id".into(),
182 neutral_type: "int64".into(),
183 nullable: false,
184 }],
185 params: vec![AnalyzedParam {
186 name: "id".into(),
187 neutral_type: "int64".into(),
188 nullable: false,
189 position: 1,
190 }],
191 deprecated: None,
192 source_table: Some("users".into()),
193 composites: vec![],
194 enums: vec![],
195 optional_params: vec![],
196 group_by: None,
197 custom: vec![CustomAnnotation {
198 name: "http".into(),
199 value: "GET /users/{id}".into(),
200 line: 1,
201 }],
202 }
203 }
204
205 fn no_http() -> AnalyzedQuery {
206 AnalyzedQuery {
207 name: "InternalQuery".to_string(),
208 command: QueryCommand::One,
209 sql: "SELECT 1".into(),
210 columns: vec![],
211 params: vec![],
212 deprecated: None,
213 source_table: None,
214 composites: vec![],
215 enums: vec![],
216 optional_params: vec![],
217 group_by: None,
218 custom: vec![],
219 }
220 }
221
222 #[test]
223 fn skips_queries_without_http_directive() {
224 let queries = vec![get_user(), no_http()];
225 let set = build_handler_set(
226 &empty_catalog(),
227 &queries,
228 &OpenApiInfo::new("t", "0.1"),
229 &BuildOptions::default(),
230 &[],
231 )
232 .unwrap();
233 assert_eq!(set.routes.len(), 1);
234 assert_eq!(set.sql_routes.len(), 1);
235 assert_eq!(set.sql_routes[0].operation_id, "GetUser");
236 }
237
238 #[test]
239 fn populates_sidecar_per_language() {
240 let queries = vec![get_user()];
241 let snake = |s: &str| snake_for(s);
242 let set = build_handler_set(
243 &empty_catalog(),
244 &queries,
245 &OpenApiInfo::new("t", "0.1"),
246 &BuildOptions::default(),
247 &[LanguageBackend {
248 name: "python",
249 scythe_module: "queries",
250 is_async: true,
251 scythe_fn_for: &snake,
252 lang_type_for: &py_type,
253 }],
254 )
255 .unwrap();
256 let entry = set.sidecar.entry_for("python", "GetUser").unwrap();
257 assert_eq!(entry.scythe_module, "queries");
258 assert_eq!(entry.scythe_fn, "get_user");
259 assert!(entry.is_async);
260 }
261
262 #[test]
263 fn openapi_emitted_in_set() {
264 let queries = vec![get_user()];
265 let set = build_handler_set(
266 &empty_catalog(),
267 &queries,
268 &OpenApiInfo::new("t", "0.1"),
269 &BuildOptions::default(),
270 &[],
271 )
272 .unwrap();
273 assert_eq!(set.openapi["openapi"], "3.1.0");
274 assert!(set.openapi["paths"]["/users/{id}"]["get"].is_object());
275 }
276}