reifydb_sub_server_http/
handlers.rs

1// Copyright (c) reifydb.com 2025
2// This file is licensed under the AGPL-3.0-or-later
3
4//! HTTP endpoint handlers for query and command execution.
5//!
6//! This module provides the request handlers for:
7//! - `/health` - Health check endpoint
8//! - `/v1/query` - Execute read-only queries
9//! - `/v1/command` - Execute write commands
10
11use axum::{
12	Json,
13	extract::State,
14	http::{HeaderMap, StatusCode},
15	response::IntoResponse,
16};
17use reifydb_sub_server::{
18	AppState, ResponseFrame, convert_frames, execute_command, execute_query, extract_identity_from_api_key,
19	extract_identity_from_auth_header,
20};
21use reifydb_type::Params;
22use serde::{Deserialize, Serialize};
23
24use crate::error::AppError;
25
26/// Request body for query and command endpoints.
27#[derive(Debug, Deserialize)]
28pub struct StatementRequest {
29	/// One or more RQL statements to execute.
30	pub statements: Vec<String>,
31	/// Optional query parameters.
32	#[serde(default)]
33	pub params: Option<Params>,
34}
35
36/// Response body for query and command endpoints.
37#[derive(Debug, Serialize)]
38pub struct QueryResponse {
39	/// Result frames from query execution.
40	pub frames: Vec<ResponseFrame>,
41}
42
43/// Health check response.
44#[derive(Debug, Serialize)]
45pub struct HealthResponse {
46	pub status: &'static str,
47}
48
49/// Health check endpoint.
50///
51/// Returns 200 OK if the server is running.
52/// This endpoint does not require authentication.
53///
54/// # Response
55///
56/// ```json
57/// {"status": "ok"}
58/// ```
59pub async fn health() -> impl IntoResponse {
60	(
61		StatusCode::OK,
62		Json(HealthResponse {
63			status: "ok",
64		}),
65	)
66}
67
68/// Execute a read-only query.
69///
70/// # Authentication
71///
72/// Requires one of:
73/// - `Authorization: Bearer <token>` header
74/// - `X-Api-Key: <key>` header
75///
76/// # Request Body
77///
78/// ```json
79/// {
80///   "statements": ["FROM users FILTER id = $1"],
81///   "params": {"$1": 42}
82/// }
83/// ```
84///
85/// # Response
86///
87/// ```json
88/// {
89///   "frames": [...]
90/// }
91/// ```
92pub async fn handle_query(
93	State(state): State<AppState>,
94	headers: HeaderMap,
95	Json(request): Json<StatementRequest>,
96) -> Result<Json<QueryResponse>, AppError> {
97	// Extract identity from headers
98	let identity = extract_identity(&headers)?;
99
100	// Combine statements
101	let query = request.statements.join("; ");
102
103	// Get params or default
104	let params = request.params.unwrap_or(Params::None);
105
106	// Execute with timeout
107	let frames = execute_query(state.engine_clone(), query, identity, params, state.query_timeout()).await?;
108
109	Ok(Json(QueryResponse {
110		frames: convert_frames(frames),
111	}))
112}
113
114/// Execute a write command.
115///
116/// Commands include INSERT, UPDATE, DELETE, and DDL statements.
117///
118/// # Authentication
119///
120/// Requires one of:
121/// - `Authorization: Bearer <token>` header
122/// - `X-Api-Key: <key>` header
123///
124/// # Request Body
125///
126/// ```json
127/// {
128///   "statements": ["INSERT INTO users (name) VALUES ($1)"],
129///   "params": {"$1": "Alice"}
130/// }
131/// ```
132///
133/// # Response
134///
135/// ```json
136/// {
137///   "frames": [...]
138/// }
139/// ```
140pub async fn handle_command(
141	State(state): State<AppState>,
142	headers: HeaderMap,
143	Json(request): Json<StatementRequest>,
144) -> Result<Json<QueryResponse>, AppError> {
145	// Extract identity from headers
146	let identity = extract_identity(&headers)?;
147
148	// Get params or default
149	let params = request.params.unwrap_or(Params::None);
150
151	// Execute with timeout
152	let frames = execute_command(state.engine_clone(), request.statements, identity, params, state.query_timeout())
153		.await?;
154
155	Ok(Json(QueryResponse {
156		frames: convert_frames(frames),
157	}))
158}
159
160/// Extract identity from request headers.
161///
162/// Tries in order:
163/// 1. Authorization header (Bearer token)
164/// 2. X-Api-Key header
165fn extract_identity(headers: &HeaderMap) -> Result<reifydb_core::interface::Identity, AppError> {
166	// Try Authorization header first
167	if let Some(auth_header) = headers.get("authorization") {
168		let auth_str = auth_header
169			.to_str()
170			.map_err(|_| AppError::Auth(reifydb_sub_server::AuthError::InvalidHeader))?;
171
172		return extract_identity_from_auth_header(auth_str).map_err(AppError::Auth);
173	}
174
175	// Try X-Api-Key header
176	if let Some(api_key) = headers.get("x-api-key") {
177		let key = api_key.to_str().map_err(|_| AppError::Auth(reifydb_sub_server::AuthError::InvalidHeader))?;
178
179		return extract_identity_from_api_key(key).map_err(AppError::Auth);
180	}
181
182	// No credentials provided
183	Err(AppError::Auth(reifydb_sub_server::AuthError::MissingCredentials))
184}
185
186#[cfg(test)]
187mod tests {
188	use super::*;
189
190	#[test]
191	fn test_statement_request_deserialization() {
192		let json = r#"{"statements": ["SELECT 1"]}"#;
193		let request: StatementRequest = serde_json::from_str(json).unwrap();
194		assert_eq!(request.statements, vec!["SELECT 1"]);
195		assert!(request.params.is_none());
196	}
197
198	#[test]
199	fn test_query_response_serialization() {
200		let response = QueryResponse {
201			frames: Vec::new(),
202		};
203		let json = serde_json::to_string(&response).unwrap();
204		assert!(json.contains("frames"));
205	}
206
207	#[test]
208	fn test_health_response_serialization() {
209		let response = HealthResponse {
210			status: "ok",
211		};
212		let json = serde_json::to_string(&response).unwrap();
213		assert_eq!(json, r#"{"status":"ok"}"#);
214	}
215}