Skip to main content

spikard_codegen/sql/
mod.rs

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)]
9//! SQL-driven HTTP handler generation.
10//!
11//! Consumes scythe's `AnalyzedQuery` IR (plus the `Catalog` it was analyzed
12//! against) and emits routes, JSON Schema validators, and an OpenAPI 3.1 spec.
13//! The HTTP vocabulary (`@http`, `@http_param`, `@http_auth`, …) lives
14//! entirely in this module — scythe knows nothing about HTTP. Spikard parses
15//! them out of `AnalyzedQuery.custom`.
16//!
17//! Pipeline:
18//!
19//! ```text
20//!   AnalyzedQuery.custom  ─►  parse_http_annotations  ─►  HttpAnnotations
21//!   AnalyzedQuery (+ Catalog) ─►  route_from_query   ─►  RouteMetadata
22//!   [RouteMetadata + AnalyzedQuery]  ─►  openapi_from_routes  ─►  Value (OpenAPI 3.1)
23//!   [AnalyzedQuery]  ─►  build_sidecar  ─►  Sidecar  (per-language call info)
24//! ```
25
26pub 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/// Aggregate output of [`build_handler_set`]: routes + OpenAPI spec + sidecar.
46#[derive(Debug, Clone)]
47pub struct HandlerSet {
48    /// One JSON `RouteMetadata` value per HTTP-annotated query.
49    pub routes: Vec<Value>,
50    /// The same routes, paired with their HTTP annotations and command —
51    /// useful for callers that need to wire OpenAPI emission or sidecar entries
52    /// without re-parsing.
53    pub sql_routes: Vec<SqlRoute>,
54    /// OpenAPI 3.1 spec built from `sql_routes`.
55    pub openapi: Value,
56    /// Per-language call info for handler-stub generators.
57    pub sidecar: Sidecar,
58}
59
60/// Walk a slice of analyzed queries and build the full handler set: routes,
61/// OpenAPI spec, and a per-language sidecar. Queries without an `@http`
62/// directive are skipped silently. The sidecar is populated by passing per-
63/// language `lang_type_for` resolvers in `languages`.
64///
65/// `languages` maps language name → `(scythe_module, scythe_fn_for, is_async,
66/// lang_type_for)` tuples. The closures are evaluated once per query, giving
67/// callers full control over per-language naming and type resolution without
68/// pulling scythe's backend trait into this crate's surface.
69pub 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
113/// Per-language inputs to [`build_handler_set`]. Each backend names itself
114/// (used as the sidecar key) and supplies callbacks that translate scythe
115/// metadata into language-native names and types.
116pub 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}