Skip to main content

reifydb_sub_server_http/
handlers.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2025 ReifyDB
3
4use std::{collections::HashMap, sync::Arc};
5
6use axum::{
7	Json,
8	extract::{Path, Query, State},
9	http::{HeaderMap, HeaderValue, Method, StatusCode, header},
10	response::{IntoResponse, Response},
11};
12use reifydb_core::{
13	actors::server::{Operation, ServerAuthResponse, ServerLogoutResponse, ServerMessage},
14	interface::catalog::binding::{Binding, BindingFormat, BindingProtocol, HttpMethod},
15	metric::ExecutionMetrics,
16};
17use reifydb_runtime::actor::reply::reply_channel;
18use reifydb_sub_server::{
19	auth::{AuthError, extract_identity_from_auth_header},
20	binding::dispatch_binding,
21	dispatch::dispatch,
22	format::WireFormat,
23	interceptor::{Protocol, RequestContext, RequestMetadata},
24	response::{CONTENT_TYPE_FRAMES, CONTENT_TYPE_RBCF, encode_frames_rbcf, resolve_response_json},
25	wire::WireParams,
26};
27use reifydb_type::{
28	params::Params,
29	value::{Value, frame::frame::Frame, identity::IdentityId, r#type::Type},
30};
31use reifydb_wire_format::json::{to::convert_frames, types::ResponseFrame};
32use serde::{Deserialize, Serialize};
33use serde_json::to_string;
34
35use crate::{error::AppError, state::HttpServerState};
36
37/// Request body for query and command endpoints.
38#[derive(Debug, Deserialize)]
39pub struct StatementRequest {
40	/// RQL string to execute.
41	pub rql: String,
42	/// Optional query parameters.
43	#[serde(default)]
44	pub params: Option<WireParams>,
45}
46
47/// Response body for query and command endpoints.
48#[derive(Debug, Serialize)]
49pub struct QueryResponse {
50	/// Result frames from query execution.
51	pub frames: Vec<ResponseFrame>,
52}
53
54/// Query parameters for response format control.
55#[derive(Debug, Deserialize)]
56pub struct FormatParams {
57	#[serde(default)]
58	pub format: WireFormat,
59	pub unwrap: Option<bool>,
60}
61
62/// Health check response.
63#[derive(Debug, Serialize)]
64pub struct HealthResponse {
65	pub status: &'static str,
66}
67
68/// Health check endpoint.
69///
70/// Returns 200 OK if the server is running.
71/// This endpoint does not require authentication.
72///
73/// # Response
74///
75/// ```json
76/// {"status": "ok"}
77/// ```
78pub async fn health() -> impl IntoResponse {
79	(
80		StatusCode::OK,
81		Json(HealthResponse {
82			status: "ok",
83		}),
84	)
85}
86
87/// Response body for logout endpoint.
88#[derive(Debug, Serialize)]
89pub struct LogoutResponse {
90	pub status: String,
91}
92
93/// Request body for authentication endpoint.
94#[derive(Debug, Deserialize)]
95pub struct AuthenticateRequest {
96	/// Authentication method: "password", "solana", "token".
97	pub method: String,
98	/// Credentials (method-specific key-value pairs).
99	#[serde(default)]
100	pub credentials: HashMap<String, String>,
101}
102
103/// Response body for authentication endpoint.
104#[derive(Debug, Serialize)]
105pub struct AuthenticateResponse {
106	/// Authentication status: "authenticated", "challenge", "failed".
107	pub status: String,
108	/// Session token (present when status is "authenticated").
109	#[serde(skip_serializing_if = "Option::is_none")]
110	pub token: Option<String>,
111	/// Identity ID (present when status is "authenticated").
112	#[serde(skip_serializing_if = "Option::is_none")]
113	pub identity: Option<String>,
114	/// Challenge ID (present when status is "challenge").
115	#[serde(skip_serializing_if = "Option::is_none")]
116	pub challenge_id: Option<String>,
117	/// Challenge payload (present when status is "challenge").
118	#[serde(skip_serializing_if = "Option::is_none")]
119	pub payload: Option<HashMap<String, String>>,
120	/// Failure reason (present when status is "failed").
121	#[serde(skip_serializing_if = "Option::is_none")]
122	pub reason: Option<String>,
123}
124
125pub async fn handle_authenticate(
126	State(state): State<HttpServerState>,
127	Json(request): Json<AuthenticateRequest>,
128) -> Result<Response, AppError> {
129	let (reply, receiver) = reply_channel();
130	let (actor_ref, _handle) = state.spawn_actor();
131	actor_ref
132		.send(ServerMessage::Authenticate {
133			method: request.method,
134			credentials: request.credentials,
135			reply,
136		})
137		.ok()
138		.ok_or_else(|| AppError::Internal("actor mailbox closed".into()))?;
139
140	let auth_response = receiver.recv().await.map_err(|_| AppError::Internal("actor stopped".into()))?;
141
142	match auth_response {
143		ServerAuthResponse::Authenticated {
144			identity,
145			token,
146		} => Ok((
147			StatusCode::OK,
148			Json(AuthenticateResponse {
149				status: "authenticated".to_string(),
150				token: Some(token),
151				identity: Some(identity.to_string()),
152				challenge_id: None,
153				payload: None,
154				reason: None,
155			}),
156		)
157			.into_response()),
158		ServerAuthResponse::Challenge {
159			challenge_id,
160			payload,
161		} => Ok((
162			StatusCode::OK,
163			Json(AuthenticateResponse {
164				status: "challenge".to_string(),
165				token: None,
166				identity: None,
167				challenge_id: Some(challenge_id),
168				payload: Some(payload),
169				reason: None,
170			}),
171		)
172			.into_response()),
173		ServerAuthResponse::Failed {
174			reason,
175		} => Ok((
176			StatusCode::UNAUTHORIZED,
177			Json(AuthenticateResponse {
178				status: "failed".to_string(),
179				token: None,
180				identity: None,
181				challenge_id: None,
182				payload: None,
183				reason: Some(reason),
184			}),
185		)
186			.into_response()),
187		ServerAuthResponse::Error(reason) => Ok((
188			StatusCode::INTERNAL_SERVER_ERROR,
189			Json(AuthenticateResponse {
190				status: "failed".to_string(),
191				token: None,
192				identity: None,
193				challenge_id: None,
194				payload: None,
195				reason: Some(reason),
196			}),
197		)
198			.into_response()),
199	}
200}
201
202pub async fn handle_logout(State(state): State<HttpServerState>, headers: HeaderMap) -> Result<Response, AppError> {
203	let auth_header = headers.get("authorization").ok_or(AppError::Auth(AuthError::MissingCredentials))?;
204	let auth_str = auth_header.to_str().map_err(|_| AppError::Auth(AuthError::InvalidHeader))?;
205	let token = auth_str.strip_prefix("Bearer ").ok_or(AppError::Auth(AuthError::InvalidHeader))?.trim();
206
207	if token.is_empty() {
208		return Err(AppError::Auth(AuthError::InvalidToken));
209	}
210
211	let (reply, receiver) = reply_channel();
212	let (actor_ref, _handle) = state.spawn_actor();
213	actor_ref
214		.send(ServerMessage::Logout {
215			token: token.to_string(),
216			reply,
217		})
218		.ok()
219		.ok_or_else(|| AppError::Internal("actor mailbox closed".into()))?;
220
221	let logout_response = receiver.recv().await.map_err(|_| AppError::Internal("actor stopped".into()))?;
222
223	match logout_response {
224		ServerLogoutResponse::Ok => Ok((
225			StatusCode::OK,
226			Json(LogoutResponse {
227				status: "ok".to_string(),
228			}),
229		)
230			.into_response()),
231		ServerLogoutResponse::InvalidToken => Err(AppError::Auth(AuthError::InvalidToken)),
232		ServerLogoutResponse::Error(reason) => Err(AppError::Internal(reason)),
233	}
234}
235
236/// Build `RequestMetadata` from HTTP headers.
237fn build_metadata(headers: &HeaderMap) -> RequestMetadata {
238	let mut metadata = RequestMetadata::new(Protocol::Http);
239	for (name, value) in headers.iter() {
240		if let Ok(v) = value.to_str() {
241			metadata.insert(name.as_str(), v);
242		}
243	}
244	metadata
245}
246
247/// Execute a read-only query.
248pub async fn handle_query(
249	State(state): State<HttpServerState>,
250	Query(format_params): Query<FormatParams>,
251	headers: HeaderMap,
252	Json(request): Json<StatementRequest>,
253) -> Result<Response, AppError> {
254	execute_and_respond(&state, Operation::Query, &headers, request, &format_params).await
255}
256
257/// Execute an admin operation.
258pub async fn handle_admin(
259	State(state): State<HttpServerState>,
260	Query(format_params): Query<FormatParams>,
261	headers: HeaderMap,
262	Json(request): Json<StatementRequest>,
263) -> Result<Response, AppError> {
264	execute_and_respond(&state, Operation::Admin, &headers, request, &format_params).await
265}
266
267/// Execute a write command.
268pub async fn handle_command(
269	State(state): State<HttpServerState>,
270	Query(format_params): Query<FormatParams>,
271	headers: HeaderMap,
272	Json(request): Json<StatementRequest>,
273) -> Result<Response, AppError> {
274	execute_and_respond(&state, Operation::Command, &headers, request, &format_params).await
275}
276
277/// Shared implementation for query, admin, and command handlers.
278///
279/// Dispatches to the ServerActor for engine execution via the shared
280/// `dispatch()` function which handles interceptors, timeout, and
281/// response conversion.
282async fn execute_and_respond(
283	state: &HttpServerState,
284	operation: Operation,
285	headers: &HeaderMap,
286	request: StatementRequest,
287	format_params: &FormatParams,
288) -> Result<Response, AppError> {
289	let identity = extract_identity(state, headers)?;
290	let metadata = build_metadata(headers);
291	let params = match request.params {
292		None => Params::None,
293		Some(wp) => wp.into_params().map_err(AppError::InvalidParams)?,
294	};
295	let ctx = RequestContext {
296		identity,
297		operation,
298		rql: request.rql,
299		params,
300		metadata,
301	};
302
303	let (frames, metrics) = dispatch(state, ctx).await?;
304
305	let mut response = match format_params.format {
306		WireFormat::Rbcf => match encode_frames_rbcf(&frames) {
307			Ok(bytes) => (StatusCode::OK, [(header::CONTENT_TYPE, CONTENT_TYPE_RBCF.to_string())], bytes)
308				.into_response(),
309			Err(e) => return Err(AppError::BadRequest(format!("RBCF encode error: {}", e))),
310		},
311		WireFormat::Json => {
312			let resolved = resolve_response_json(frames, format_params.unwrap.unwrap_or(false))
313				.map_err(AppError::BadRequest)?;
314			(StatusCode::OK, [(header::CONTENT_TYPE, resolved.content_type)], resolved.body).into_response()
315		}
316		WireFormat::Frames => {
317			let body = to_string(&QueryResponse {
318				frames: convert_frames(&frames),
319			})
320			.map_err(|e| AppError::BadRequest(format!("JSON encode error: {}", e)))?;
321			(StatusCode::OK, [(header::CONTENT_TYPE, CONTENT_TYPE_FRAMES.to_string())], body)
322				.into_response()
323		}
324	};
325	insert_meta_headers(response.headers_mut(), &metrics);
326	Ok(response)
327}
328
329/// Extract identity from request headers.
330///
331/// Tries in order:
332/// 1. Authorization header (Bearer token)
333/// 2. Falls back to anonymous identity
334fn extract_identity(state: &HttpServerState, headers: &HeaderMap) -> Result<IdentityId, AppError> {
335	// Try Authorization header
336	if let Some(auth_header) = headers.get("authorization") {
337		let auth_str = auth_header.to_str().map_err(|_| AppError::Auth(AuthError::InvalidHeader))?;
338
339		return extract_identity_from_auth_header(state.auth_service(), auth_str).map_err(AppError::Auth);
340	}
341
342	// No credentials provided - anonymous access
343	Ok(IdentityId::anonymous())
344}
345
346/// Handler for binding-driven requests mounted at `/api/{*path}`.
347///
348/// Resolves the HTTP method + remaining path to a `Binding` in the materialized
349/// catalog via linear scan, coerces path + query params against the bound
350/// procedure's declared parameter types, and dispatches through the shared
351/// `dispatch_binding` helper.
352pub async fn handle_binding(
353	State(state): State<HttpServerState>,
354	Path(path): Path<String>,
355	method: Method,
356	Query(query_params): Query<HashMap<String, String>>,
357	headers: HeaderMap,
358) -> Result<Response, AppError> {
359	let http_method = match method.as_str() {
360		"GET" => HttpMethod::Get,
361		"POST" => HttpMethod::Post,
362		"PUT" => HttpMethod::Put,
363		"PATCH" => HttpMethod::Patch,
364		"DELETE" => HttpMethod::Delete,
365		_ => return Err(AppError::MethodNotAllowed(format!("method `{}` is not supported", method))),
366	};
367	let request_path = format!("/{}", path);
368
369	// Resolve binding via linear scan over the HTTP-only index (path patterns require scanning).
370	let bindings = state.engine().materialized_catalog().list_http_bindings();
371	let mut any_path_match = false;
372	let mut matched: Option<(Binding, HashMap<String, String>)> = None;
373	for b in &bindings {
374		let BindingProtocol::Http {
375			method: binding_method,
376			path: binding_path,
377		} = &b.protocol
378		else {
379			unreachable!("list_http_bindings returns only HTTP bindings")
380		};
381		if let Some(captures) = match_http_path(binding_path, &request_path) {
382			any_path_match = true;
383			if binding_method == &http_method {
384				matched = Some((b.clone(), captures));
385				break;
386			}
387		}
388	}
389	let (binding, path_captures) = match matched {
390		Some(m) => m,
391		None if any_path_match => {
392			return Err(AppError::MethodNotAllowed(format!(
393				"no binding for method `{}` at `{}`",
394				method, request_path
395			)));
396		}
397		None => return Err(AppError::NotFound(format!("no binding for `{}`", request_path))),
398	};
399
400	// Resolve procedure + namespace from the binding.
401	let procedure =
402		state.engine().materialized_catalog().find_procedure(binding.procedure_id).ok_or_else(|| {
403			AppError::Internal(format!(
404				"binding references missing procedure id {:?}",
405				binding.procedure_id
406			))
407		})?;
408	let namespace = state.engine().materialized_catalog().find_namespace(binding.namespace).ok_or_else(|| {
409		AppError::Internal(format!("binding references missing namespace id {:?}", binding.namespace))
410	})?;
411
412	let param_names: Vec<&str> = procedure.params().iter().map(|p| p.name.as_str()).collect();
413	for key in query_params.keys() {
414		if !param_names.contains(&key.as_str()) {
415			return Err(AppError::BadRequest(format!("unknown parameter `{}`", key)));
416		}
417		if path_captures.contains_key(key) {
418			return Err(AppError::BadRequest(format!("parameter `{}` given in both path and query", key)));
419		}
420	}
421
422	let mut params: HashMap<String, Value> = HashMap::with_capacity(procedure.params().len());
423	for p in procedure.params() {
424		let raw = match path_captures.get(&p.name).or_else(|| query_params.get(&p.name)) {
425			Some(v) => v,
426			None => {
427				return Err(AppError::BadRequest(format!("missing required parameter `{}`", p.name)));
428			}
429		};
430		let value = coerce_str_to_value(raw, p.param_type.get_type()).map_err(|e| {
431			AppError::BadRequest(format!(
432				"parameter `{}`: cannot coerce `{}` to {:?}: {}",
433				p.name,
434				raw,
435				p.param_type.get_type(),
436				e
437			))
438		})?;
439		params.insert(p.name.clone(), value);
440	}
441	let params = if params.is_empty() {
442		Params::None
443	} else {
444		Params::Named(Arc::new(params))
445	};
446
447	let identity = extract_identity(&state, &headers)?;
448	let metadata = build_metadata(&headers);
449
450	let (frames, metrics) =
451		dispatch_binding(&state, namespace.name(), procedure.name(), params, identity, metadata).await?;
452
453	let mut response = encode_binding_response(frames, binding.format)?;
454	insert_meta_headers(response.headers_mut(), &metrics);
455	Ok(response)
456}
457
458fn insert_meta_headers(headers: &mut HeaderMap, metrics: &ExecutionMetrics) {
459	headers.insert("x-fingerprint", HeaderValue::from_str(&metrics.fingerprint.to_hex()).unwrap());
460	headers.insert("x-duration", HeaderValue::from_str(&metrics.total.to_string()).unwrap());
461}
462
463/// Match an HTTP binding path template against a concrete request path.
464/// Templates use `{var}` for path captures. Returns the captured map, or `None` if no match.
465fn match_http_path(template: &str, request: &str) -> Option<HashMap<String, String>> {
466	let t_segments: Vec<&str> = template.split('/').filter(|s| !s.is_empty()).collect();
467	let r_segments: Vec<&str> = request.split('/').filter(|s| !s.is_empty()).collect();
468	if t_segments.len() != r_segments.len() {
469		return None;
470	}
471	let mut captures = HashMap::new();
472	for (t, r) in t_segments.iter().zip(r_segments.iter()) {
473		if t.starts_with('{') && t.ends_with('}') {
474			let var = &t[1..t.len() - 1];
475			captures.insert(var.to_string(), r.to_string());
476		} else if t != r {
477			return None;
478		}
479	}
480	Some(captures)
481}
482
483fn coerce_str_to_value(s: &str, ty: Type) -> Result<Value, String> {
484	match ty {
485		Type::Boolean => match s {
486			"true" | "1" => Ok(Value::Boolean(true)),
487			"false" | "0" => Ok(Value::Boolean(false)),
488			_ => Err("expected `true`/`false`".into()),
489		},
490		Type::Utf8 => Ok(Value::Utf8(s.to_string())),
491		Type::Int1 => s.parse::<i8>().map(Value::Int1).map_err(|e| e.to_string()),
492		Type::Int2 => s.parse::<i16>().map(Value::Int2).map_err(|e| e.to_string()),
493		Type::Int4 => s.parse::<i32>().map(Value::Int4).map_err(|e| e.to_string()),
494		Type::Int8 => s.parse::<i64>().map(Value::Int8).map_err(|e| e.to_string()),
495		Type::Int16 => s.parse::<i128>().map(Value::Int16).map_err(|e| e.to_string()),
496		Type::Uint1 => s.parse::<u8>().map(Value::Uint1).map_err(|e| e.to_string()),
497		Type::Uint2 => s.parse::<u16>().map(Value::Uint2).map_err(|e| e.to_string()),
498		Type::Uint4 => s.parse::<u32>().map(Value::Uint4).map_err(|e| e.to_string()),
499		Type::Uint8 => s.parse::<u64>().map(Value::Uint8).map_err(|e| e.to_string()),
500		Type::Uint16 => s.parse::<u128>().map(Value::Uint16).map_err(|e| e.to_string()),
501		Type::Float4 => s
502			.parse::<f32>()
503			.map_err(|e| e.to_string())
504			.and_then(|v| v.try_into().map(Value::Float4).map_err(|_| "invalid f32".to_string())),
505		Type::Float8 => s
506			.parse::<f64>()
507			.map_err(|e| e.to_string())
508			.and_then(|v| v.try_into().map(Value::Float8).map_err(|_| "invalid f64".to_string())),
509		other => Err(format!("coercion to {:?} not supported from URL strings", other)),
510	}
511}
512
513fn encode_binding_response(frames: Vec<Frame>, format: BindingFormat) -> Result<Response, AppError> {
514	match format {
515		BindingFormat::Rbcf => match encode_frames_rbcf(&frames) {
516			Ok(bytes) => {
517				Ok((StatusCode::OK, [(header::CONTENT_TYPE, CONTENT_TYPE_RBCF.to_string())], bytes)
518					.into_response())
519			}
520			Err(e) => Err(AppError::BadRequest(format!("RBCF encode error: {}", e))),
521		},
522		BindingFormat::Json => {
523			let resolved = resolve_response_json(frames, false).map_err(AppError::BadRequest)?;
524			Ok((StatusCode::OK, [(header::CONTENT_TYPE, resolved.content_type)], resolved.body)
525				.into_response())
526		}
527		BindingFormat::Frames => Ok(Json(QueryResponse {
528			frames: convert_frames(&frames),
529		})
530		.into_response()),
531	}
532}
533
534#[cfg(test)]
535pub mod tests {
536	use serde_json::from_str;
537
538	use super::*;
539
540	#[test]
541	fn test_match_http_path_static() {
542		assert_eq!(match_http_path("/users", "/users"), Some(HashMap::new()));
543		assert_eq!(match_http_path("/users", "/other"), None);
544	}
545
546	#[test]
547	fn test_match_http_path_capture() {
548		let caps = match_http_path("/users/{id}", "/users/42").unwrap();
549		assert_eq!(caps.get("id"), Some(&"42".to_string()));
550	}
551
552	#[test]
553	fn test_match_http_path_mismatch_length() {
554		assert!(match_http_path("/users/{id}", "/users").is_none());
555		assert!(match_http_path("/users/{id}", "/users/42/extra").is_none());
556	}
557
558	#[test]
559	fn test_coerce_numeric() {
560		assert_eq!(coerce_str_to_value("42", Type::Int8).unwrap(), Value::Int8(42));
561		assert!(coerce_str_to_value("xx", Type::Int8).is_err());
562	}
563
564	#[test]
565	fn test_coerce_bool() {
566		assert_eq!(coerce_str_to_value("true", Type::Boolean).unwrap(), Value::Boolean(true));
567		assert!(coerce_str_to_value("maybe", Type::Boolean).is_err());
568	}
569
570	#[test]
571	fn test_statement_request_deserialization() {
572		let json = r#"{"rql": "SELECT 1"}"#;
573		let request: StatementRequest = from_str(json).unwrap();
574		assert_eq!(request.rql, "SELECT 1");
575		assert!(request.params.is_none());
576	}
577
578	#[test]
579	fn test_query_response_serialization() {
580		let response = QueryResponse {
581			frames: Vec::new(),
582		};
583		let json = to_string(&response).unwrap();
584		assert!(json.contains("frames"));
585	}
586
587	#[test]
588	fn test_health_response_serialization() {
589		let response = HealthResponse {
590			status: "ok",
591		};
592		let json = to_string(&response).unwrap();
593		assert_eq!(json, r#"{"status":"ok"}"#);
594	}
595}