1use serde_json::{Map, Value};
20use tiberius::ToSql;
21
22use crate::auth::auth_manager::AuthManager;
23use crate::core::config_schema::Config;
24use crate::data::store::EndpointRecord;
25use crate::services::sql_pool;
26use crate::services::sql_type::{column_data_to_json, json_to_param};
27use crate::validation::validator::resolved_schemas_for;
28
29fn parse_path(path: &str) -> anyhow::Result<(&str, &str, &str)> {
35 let mut parts = path.trim_start_matches('/').splitn(3, '/');
36 match (parts.next(), parts.next(), parts.next()) {
37 (Some(db), Some(schema), Some(name))
38 if !db.is_empty() && !schema.is_empty() && !name.is_empty() =>
39 {
40 Ok((
41 validate_ident(db)?,
42 validate_ident(schema)?,
43 validate_ident(name)?,
44 ))
45 }
46 _ => anyhow::bail!(
47 "endpoint path '{path}' is not in the expected /<db>/<schema>/<name> shape"
48 ),
49 }
50}
51
52fn validate_ident(ident: &str) -> anyhow::Result<&str> {
67 if !ident.is_empty()
68 && ident
69 .bytes()
70 .all(|b| b.is_ascii_alphanumeric() || b == b'_')
71 {
72 Ok(ident)
73 } else {
74 anyhow::bail!("identifier '{ident}' contains characters outside [A-Za-z0-9_]")
75 }
76}
77
78fn quote_ident(ident: &str) -> String {
84 format!("[{}]", ident.replace(']', "]]"))
85}
86
87struct Param {
88 name: String,
89 ordinal: u64,
90 x_sql_type: String,
91}
92
93fn ordered_params(input_schema: &Value) -> Vec<Param> {
99 let mut params: Vec<Param> = input_schema
100 .get("properties")
101 .and_then(Value::as_object)
102 .into_iter()
103 .flatten()
104 .map(|(name, schema)| Param {
105 name: name.clone(),
106 ordinal: schema
107 .get("x-sql-ordinal")
108 .and_then(Value::as_u64)
109 .unwrap_or(0),
110 x_sql_type: schema
111 .get("x-sql-type")
112 .and_then(Value::as_str)
113 .unwrap_or("nvarchar(max)")
114 .to_string(),
115 })
116 .collect();
117 params.sort_by_key(|p| p.ordinal);
118 params
119}
120
121fn build_statement(
124 db: &str,
125 schema: &str,
126 name: &str,
127 kind: Option<&str>,
128 params: &[Param],
129 body: &Map<String, Value>,
130 database_override: Option<&str>,
131) -> anyhow::Result<(String, Vec<Box<dyn ToSql>>)> {
132 let qualified = match (db, database_override) {
150 ("sandbox", Some(requested_db)) => format!(
151 "{}.{}.{}",
152 quote_ident(requested_db),
153 quote_ident(schema),
154 quote_ident(name)
155 ),
156 ("sandbox", None) => format!("{}.{}", quote_ident(schema), quote_ident(name)),
157 (_, _) => format!(
158 "{}.{}.{}",
159 quote_ident(db),
160 quote_ident(schema),
161 quote_ident(name)
162 ),
163 };
164
165 let mut bound: Vec<Box<dyn ToSql>> = Vec::with_capacity(params.len());
166 for param in params {
167 let value = body.get(¶m.name).cloned().unwrap_or(Value::Null);
168 bound.push(json_to_param(&value, ¶m.x_sql_type)?);
169 }
170
171 let sql = match kind {
172 Some("VIEW") => format!("SELECT * FROM {qualified}"),
173 Some(k) if k.ends_with("_FUNCTION") || k.contains("TABLE_VALUED_FUNCTION") => {
174 let placeholders = (1..=bound.len())
175 .map(|i| format!("@P{i}"))
176 .collect::<Vec<_>>()
177 .join(", ");
178 format!("SELECT * FROM {qualified}({placeholders})")
179 }
180 _ => {
187 if bound.is_empty() {
188 format!("EXEC {qualified}")
189 } else {
190 let mut assignments = Vec::with_capacity(params.len());
191 for (i, p) in params.iter().enumerate() {
192 assignments.push(format!(
193 "{} = @P{}",
194 quote_ident(validate_ident(&p.name)?),
195 i + 1
196 ));
197 }
198 format!("EXEC {qualified} {}", assignments.join(", "))
199 }
200 }
201 };
202
203 Ok((sql, bound))
204}
205
206fn classify_tiberius_error(err: tiberius::error::Error) -> anyhow::Error {
216 use tiberius::error::Error as TError;
217 match &err {
218 TError::Server(token) => {
219 let (number, state, class, message, procedure, line) = (
220 token.code(),
221 token.state(),
222 token.class(),
223 token.message(),
224 token.procedure(),
225 token.line(),
226 );
227 let category = if class >= 17 {
228 "fatal/resource error (500-equivalent)"
229 } else if class == 14 {
230 "permission denied (403-equivalent)"
231 } else {
232 "statement/user error (400-equivalent)"
233 };
234 anyhow::anyhow!(
235 "SQL Server error {number} (severity {class}, state {state}) in '{procedure}' line {line}: {message} [{category}]"
236 )
237 }
238 other => anyhow::anyhow!("SQL Server connection/protocol error (500-equivalent): {other}"),
239 }
240}
241
242pub struct ApiClient {
243 config: Config,
244}
245
246impl ApiClient {
247 pub fn new(config: Config) -> Self {
248 Self { config }
249 }
250
251 pub async fn execute(
269 &self,
270 endpoint: &EndpointRecord,
271 args: &Value,
272 auth_manager: &mut AuthManager,
273 ) -> anyhow::Result<Value> {
274 let (db, schema, name) = parse_path(&endpoint.path)?;
275
276 let (input_schema, _output_schema) =
277 resolved_schemas_for(&self.config.api_version, &endpoint.operation_id).ok_or_else(
278 || {
279 anyhow::anyhow!(
280 "no resolved schema found for operation '{}' under api_version '{}'",
281 endpoint.operation_id,
282 self.config.api_version
283 )
284 },
285 )?;
286 let params = ordered_params(input_schema);
287
288 let empty = Map::new();
289 let args_map = args.as_object().unwrap_or(&empty);
290 let body = args_map
291 .get("body")
292 .and_then(Value::as_object)
293 .cloned()
294 .unwrap_or_default();
295 let database_override = args_map
296 .get("database")
297 .and_then(Value::as_str)
298 .map(validate_ident)
299 .transpose()?;
300
301 let (sql, bound) = build_statement(
302 db,
303 schema,
304 name,
305 endpoint.description.as_deref(),
306 ¶ms,
307 &body,
308 database_override,
309 )?;
310
311 let auth_method = auth_manager.resolve_tds_auth().await?;
312
313 let (host, port) = self.config.host_and_port();
314 let mut tiberius_config = tiberius::Config::new();
315 tiberius_config.host(host);
316 tiberius_config.port(port);
317 tiberius_config.authentication(auth_method);
318 if self.config.trust_server_cert {
319 tiberius_config.trust_cert();
327 }
328
329 let pool_key = format!("{host}:{port}");
330 let pool =
331 sql_pool::cached_pool(&pool_key, tiberius_config, self.config.pool_max_size).await?;
332 let mut conn = pool.get().await.map_err(|err| {
333 anyhow::anyhow!("failed to obtain a pooled SQL Server connection: {err}")
334 })?;
335
336 let bound_refs: Vec<&dyn ToSql> = bound.iter().map(|param| param.as_ref()).collect();
337 let stream = conn
338 .query(&sql, &bound_refs)
339 .await
340 .map_err(classify_tiberius_error)?;
341 let rows = stream
342 .into_first_result()
343 .await
344 .map_err(classify_tiberius_error)?;
345
346 let json_rows: Vec<Value> = rows
347 .iter()
348 .map(|row| {
349 let mut obj = Map::new();
350 for (column, data) in row.cells() {
351 obj.insert(column.name().to_string(), column_data_to_json(data));
352 }
353 Value::Object(obj)
354 })
355 .collect();
356 Ok(Value::Array(json_rows))
357 }
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363
364 #[test]
365 fn parse_path_splits_db_schema_name() {
366 assert_eq!(
367 parse_path("/master/INFORMATION_SCHEMA/COLUMNS").unwrap(),
368 ("master", "INFORMATION_SCHEMA", "COLUMNS")
369 );
370 }
371
372 #[test]
373 fn parse_path_rejects_a_path_with_too_few_segments() {
374 assert!(parse_path("/master/COLUMNS").is_err());
375 }
376
377 #[test]
378 fn quote_ident_doubles_embedded_closing_brackets() {
379 assert_eq!(quote_ident("weird]name"), "[weird]]name]");
380 }
381
382 #[test]
383 fn validate_ident_accepts_alphanumeric_and_underscore() {
384 assert!(validate_ident("sp_who2").is_ok());
385 assert!(validate_ident("INFORMATION_SCHEMA").is_ok());
386 }
387
388 #[test]
389 fn validate_ident_rejects_anything_else() {
390 assert!(validate_ident("").is_err());
391 assert!(validate_ident("robert'; drop table endpoints;--").is_err());
392 assert!(validate_ident("weird]name").is_err());
393 assert!(validate_ident("has space").is_err());
394 }
395
396 #[test]
397 fn parse_path_rejects_a_path_with_an_unsafe_segment() {
398 assert!(parse_path("/master/sys/sp_who; DROP TABLE endpoints--").is_err());
399 }
400
401 #[test]
402 fn build_statement_selects_from_a_view_with_no_parameters() {
403 let (sql, bound) = build_statement(
404 "master",
405 "INFORMATION_SCHEMA",
406 "COLUMNS",
407 Some("VIEW"),
408 &[],
409 &Map::new(),
410 None,
411 )
412 .unwrap();
413 assert_eq!(sql, "SELECT * FROM [master].[INFORMATION_SCHEMA].[COLUMNS]");
414 assert!(bound.is_empty());
415 }
416
417 #[test]
418 fn build_statement_execs_a_proc_with_named_parameters() {
419 let params = vec![
420 Param {
421 name: "objname".to_string(),
422 ordinal: 1,
423 x_sql_type: "nvarchar(1035)".to_string(),
424 },
425 Param {
426 name: "newname".to_string(),
427 ordinal: 2,
428 x_sql_type: "sysname".to_string(),
429 },
430 ];
431 let mut body = Map::new();
432 body.insert("objname".to_string(), Value::String("t1".to_string()));
433 body.insert("newname".to_string(), Value::String("t2".to_string()));
434 let (sql, bound) = build_statement(
435 "master",
436 "sys",
437 "sp_rename",
438 Some("SQL_STORED_PROCEDURE"),
439 ¶ms,
440 &body,
441 None,
442 )
443 .unwrap();
444 assert_eq!(
445 sql,
446 "EXEC [master].[sys].[sp_rename] [objname] = @P1, [newname] = @P2"
447 );
448 assert_eq!(bound.len(), 2);
449 }
450
451 #[test]
452 fn build_statement_selects_from_a_function_with_positional_placeholders() {
453 let params = vec![Param {
454 name: "session_id".to_string(),
455 ordinal: 1,
456 x_sql_type: "smallint".to_string(),
457 }];
458 let mut body = Map::new();
459 body.insert("session_id".to_string(), Value::from(52));
460 let (sql, bound) = build_statement(
461 "master",
462 "sys",
463 "dm_exec_sql_text",
464 Some("SQL_INLINE_TABLE_VALUED_FUNCTION"),
465 ¶ms,
466 &body,
467 None,
468 )
469 .unwrap();
470 assert_eq!(sql, "SELECT * FROM [master].[sys].[dm_exec_sql_text](@P1)");
471 assert_eq!(bound.len(), 1);
472 }
473
474 #[test]
475 fn build_statement_omits_the_database_qualifier_for_sandbox_by_default() {
476 let (sql, bound) = build_statement(
477 "sandbox",
478 "dbo",
479 "widgets",
480 Some("VIEW"),
481 &[],
482 &Map::new(),
483 None,
484 )
485 .unwrap();
486 assert_eq!(sql, "SELECT * FROM [dbo].[widgets]");
487 assert!(bound.is_empty());
488 }
489
490 #[test]
491 fn build_statement_replaces_sandbox_with_a_requested_database_override() {
492 let (sql, bound) = build_statement(
493 "sandbox",
494 "dbo",
495 "widgets",
496 Some("VIEW"),
497 &[],
498 &Map::new(),
499 Some("reporting"),
500 )
501 .unwrap();
502 assert_eq!(sql, "SELECT * FROM [reporting].[dbo].[widgets]");
503 assert!(bound.is_empty());
504 }
505
506 #[test]
507 fn build_statement_ignores_the_database_override_for_master_and_msdb() {
508 let (sql, _bound) = build_statement(
509 "master",
510 "sys",
511 "sp_who",
512 Some("SQL_STORED_PROCEDURE"),
513 &[],
514 &Map::new(),
515 Some("reporting"),
516 )
517 .unwrap();
518 assert_eq!(sql, "EXEC [master].[sys].[sp_who]");
519 }
520
521 #[test]
522 fn build_statement_execs_a_parameterless_proc() {
523 let (sql, bound) = build_statement(
524 "master",
525 "sys",
526 "sp_who",
527 Some("SQL_STORED_PROCEDURE"),
528 &[],
529 &Map::new(),
530 None,
531 )
532 .unwrap();
533 assert_eq!(sql, "EXEC [master].[sys].[sp_who]");
534 assert!(bound.is_empty());
535 }
536
537 #[test]
538 fn ordered_params_follow_the_schema_ordinals() {
539 let schema = serde_json::json!({
540 "properties": {
541 "second": { "x-sql-ordinal": 2, "x-sql-type": "int" },
542 "first": { "x-sql-ordinal": 1, "x-sql-type": "nvarchar(20)" }
543 }
544 });
545
546 let params = ordered_params(&schema);
547 assert_eq!(params.len(), 2);
548 assert_eq!(params[0].name, "first");
549 assert_eq!(params[0].x_sql_type, "nvarchar(20)");
550 assert_eq!(params[1].name, "second");
551 }
552
553 #[test]
554 fn build_statement_rejects_an_unsafe_parameter_name() {
555 let params = vec![Param {
556 name: "unsafe name".to_string(),
557 ordinal: 1,
558 x_sql_type: "int".to_string(),
559 }];
560
561 assert!(
562 build_statement(
563 "master",
564 "sys",
565 "sp_example",
566 Some("SQL_STORED_PROCEDURE"),
567 ¶ms,
568 &Map::new(),
569 None,
570 )
571 .is_err()
572 );
573 }
574
575 #[test]
576 fn protocol_errors_are_classified_as_server_failures() {
577 let error =
578 classify_tiberius_error(tiberius::error::Error::Protocol("invalid packet".into()));
579 assert_eq!(
580 error.to_string(),
581 "SQL Server connection/protocol error (500-equivalent): Protocol error: invalid packet"
582 );
583 }
584}