1use axum::{
2 http::{HeaderValue, Method, StatusCode},
3 middleware,
4 routing::{delete, get, post},
5 Router,
6};
7use std::time::Duration;
8use tower_http::{
9 cors::CorsLayer, limit::RequestBodyLimitLayer, timeout::TimeoutLayer, trace::TraceLayer,
10};
11
12use crate::storage::UndoTarget;
13use crate::storage::{
14 StorageClient, StorageOperationContextOps, StorageSchemaContextOps, StorageSnapshotOps,
15 StorageSyncContextOps,
16};
17
18#[cfg(feature = "vector")]
19use super::handlers::vector;
20
21#[cfg(feature = "fulltext")]
22use super::handlers::rebuild as rebuild_handlers;
23
24use super::{
25 handlers::{
26 auth::{login, logout, me},
27 batch::{
28 add_items, cancel as cancel_batch, create as create_batch, delete as delete_batch,
29 execute as execute_batch, status as batch_status,
30 },
31 config::{get as get_config, get_key, reset_key, update as update_config, update_key},
32 function::{info as function_info, list, register, unregister},
33 health, query, schema,
34 session::{create as create_session, delete_session, get_session, list_sessions},
35 statistics::{
36 database, freeze_stats, overview, queries, query_profile_detail,
37 search as search_stats, session, system, trigger_freeze,
38 },
39 stream::execute_stream,
40 sync, transaction,
41 },
42 middleware::{auth::auth_middleware, error, logging},
43 state::AppState,
44};
45
46pub fn create_router<
58 S: StorageClient
59 + StorageSchemaContextOps
60 + StorageSnapshotOps
61 + StorageSyncContextOps
62 + StorageOperationContextOps
63 + crate::storage::AutoCommitBatchOps
64 + crate::storage::AutoCommitGroupOps
65 + UndoTarget
66 + Clone
67 + Send
68 + Sync
69 + 'static,
70>(
71 state: AppState<S>,
72 web_router: Option<Router>,
73) -> Router {
74 let public_routes = Router::new()
76 .route("/health", get(health::check))
77 .route("/metrics", get(super::handlers::metrics::metrics))
78 .route("/auth/login", post(login))
79 .route(
80 "/sessions",
84 post(create_session),
85 );
86
87 let protected_routes = Router::new()
89 .route("/auth/logout", post(logout))
90 .route("/auth/me", get(me))
91 .route(
92 "/users",
93 get(super::handlers::users::list).post(super::handlers::users::create),
94 )
95 .route("/users/{name}", delete(super::handlers::users::drop_user))
96 .route(
97 "/users/{name}/password",
98 post(super::handlers::users::reset_password),
99 )
100 .route("/users/{name}/enable", post(super::handlers::users::enable))
101 .route(
102 "/users/{name}/disable",
103 post(super::handlers::users::disable),
104 )
105 .route("/users/{name}/grant", post(super::handlers::users::grant))
106 .route("/users/{name}/revoke", post(super::handlers::users::revoke))
107 .route("/sessions", post(create_session).get(list_sessions))
108 .route("/sessions/{id}", get(get_session).delete(delete_session))
109 .route("/query", post(query::execute))
110 .route("/query/validate", post(query::validate))
111 .route("/query/explain", post(query::explain))
112 .route("/query/batch", post(query::execute_batch))
113 .route(
114 "/transactions",
115 post(transaction::begin).get(transaction::list_transactions),
116 )
117 .route("/transactions/metrics", get(transaction::metrics))
118 .route("/transactions/{id}/commit", post(transaction::commit))
119 .route("/transactions/{id}/rollback", post(transaction::rollback))
120 .route(
121 "/transactions/{id}/kill",
122 post(transaction::kill_transaction),
123 )
124 .route(
125 "/transactions/{id}/outbox/retry",
126 post(transaction::retry_transaction_outbox),
127 )
128 .route(
129 "/transactions/{id}/savepoints",
130 post(transaction::create_savepoint).get(transaction::get_savepoints),
131 )
132 .route(
133 "/transactions/{id}/savepoints/{sid}/rollback",
134 post(transaction::rollback_to_savepoint),
135 )
136 .route(
137 "/transactions/{id}/savepoints/{sid}",
138 delete(transaction::release_savepoint),
139 )
140 .route("/batch", post(create_batch))
142 .route("/batch/{id}", get(batch_status).delete(delete_batch))
143 .route("/batch/{id}/items", post(add_items))
144 .route("/batch/{id}/execute", post(execute_batch))
145 .route("/batch/{id}/cancel", post(cancel_batch))
146 .route("/import", post(super::handlers::import::import_file))
148 .route("/import/{id}", get(super::handlers::import::import_status))
149 .route("/export", get(super::handlers::export::export_data))
150 .route("/statistics/sessions/{id}", get(session))
152 .route("/statistics/queries", get(queries))
153 .route("/statistics/queries/{trace_id}", get(query_profile_detail))
154 .route("/statistics/overview", get(overview))
155 .route("/statistics/database", get(database))
156 .route("/statistics/system", get(system))
157 .route("/statistics/search", get(search_stats))
158 .route("/statistics/freeze", get(freeze_stats).post(trigger_freeze))
159 .route(
160 "/statistics/migration",
161 get(super::handlers::statistics::migration),
162 )
163 .route("/config", get(get_config).put(update_config))
165 .route(
166 "/config/{section}/{key}",
167 get(get_key).put(update_key).delete(reset_key),
168 )
169 .route("/functions", post(register).get(list))
171 .route("/functions/{name}", get(function_info).delete(unregister))
172 .route("/query/stream", post(execute_stream))
174 .route(
176 "/query/cursor/open",
177 post(super::handlers::cursor::open_cursor),
178 )
179 .route(
180 "/query/cursor/fetch",
181 post(super::handlers::cursor::fetch_cursor),
182 )
183 .route(
184 "/query/cursor/close",
185 post(super::handlers::cursor::close_cursor),
186 )
187 .route("/sync/status", get(sync::status))
189 .route("/sync/outbox/retry", post(sync::retry_outbox))
190 .route("/sync/outbox/diagnostics", get(sync::diagnostics))
191 .route("/sync/outbox/dead_letters", get(sync::dead_letters))
192 .route("/sync/outbox/requeue", post(sync::requeue))
193 .route("/sync/outbox/degraded_ranges", get(sync::degraded_ranges))
194 .route("/sync/outbox/degraded/clear", post(sync::degraded_clear))
195 .route("/sync/outbox/retention/run", post(sync::retention_run))
196 .route("/sync/outbox/retention/status", get(sync::retention_status))
197 .route(
199 "/schema/spaces",
200 post(schema::create_space).get(schema::list_spaces),
201 )
202 .route(
203 "/schema/spaces/{name}",
204 get(schema::get_space).delete(schema::drop_space),
205 )
206 .route(
207 "/schema/spaces/{name}/tags",
208 post(schema::create_tag).get(schema::list_tags),
209 )
210 .route(
211 "/schema/spaces/{name}/edge-types",
212 post(schema::create_edge_type).get(schema::list_edge_types),
213 )
214 .route(
216 "/schema/versions/{space}/{label}",
217 get(schema::get_version_history),
218 )
219 .route(
220 "/schema/changes/{space}/{label}/{from_version}/{to_version}",
221 get(schema::get_schema_changes),
222 )
223 .route(
224 "/schema/breaking-changes/{space}/{label}/{from_version}/{to_version}",
225 get(schema::detect_breaking_changes),
226 )
227 .route(
229 "/migration/plan/{space}/{label}",
230 post(schema::create_migration_plan),
231 )
232 .route("/migration/execute", post(schema::execute_migration))
233 .route("/migration/rollback", post(schema::rollback_migration))
234 .route("/migration/dry-run", post(schema::dry_run_migration))
235 .route(
236 "/migration/history/{space}/{label}",
237 get(schema::migration_history),
238 )
239 .route(
240 "/migration/status/{space}/{label}",
241 get(schema::migration_status),
242 )
243 .route(
244 "/migration/stream/{space}/{label}",
245 get(crate::http::handlers::migration_progress::migration_progress_stream),
246 )
247 .layer(middleware::from_fn_with_state(
248 state.clone(),
249 auth_middleware,
250 ));
251
252 let protected_routes = add_vector_routes(protected_routes);
253 let protected_routes = add_fulltext_routes(protected_routes);
254
255 let router = Router::new()
256 .nest("/v1", public_routes.merge(protected_routes))
257 .layer(middleware::from_fn(logging::logging_middleware))
258 .layer(middleware::from_fn(error::error_handling_middleware))
259 .layer(TraceLayer::new_for_http())
260 .layer(TimeoutLayer::with_status_code(
261 StatusCode::REQUEST_TIMEOUT,
262 Duration::from_secs(30),
263 ))
264 .layer(RequestBodyLimitLayer::new(1024 * 1024 * 10)) .with_state(state.clone());
266
267 let http_config = state.server.get_config().server.http.clone();
271 let router = match create_cors_layer(&http_config) {
272 Some(cors) => router.layer(cors),
273 None => {
274 log::info!("CORS layer disabled by configuration");
275 router
276 }
277 };
278
279 let mut router = if let Some(wr) = web_router {
281 router.nest("/api", wr)
282 } else {
283 router
284 };
285
286 if cfg!(debug_assertions) {
288 router = router.route("/api-docs/openapi.json", get(serve_openapi));
289 }
290
291 router
292}
293
294async fn serve_openapi() -> ([(&'static str, &'static str); 1], String) {
296 (
297 [("Content-Type", "application/json")],
298 super::openapi::openapi_json(),
299 )
300}
301
302#[cfg(feature = "fulltext")]
304fn add_fulltext_routes<
305 S: crate::storage::StorageClient
306 + crate::storage::StorageSchemaContextOps
307 + crate::storage::StorageSyncContextOps
308 + crate::storage::StorageOperationContextOps
309 + Clone
310 + Send
311 + Sync
312 + 'static,
313>(
314 router: Router<super::state::AppState<S>>,
315) -> Router<super::state::AppState<S>> {
316 router
317 .route(
318 "/fulltext/indexes/rebuild",
319 post(rebuild_handlers::rebuild_fulltext),
320 )
321 .route(
322 "/fulltext/rebuilds/{id}",
323 get(rebuild_handlers::fulltext_rebuild_status),
324 )
325 .route(
326 "/fulltext/indexes/clear",
327 post(rebuild_handlers::clear_fulltext),
328 )
329 .route(
330 "/fulltext/indexes/inconsistent",
331 get(rebuild_handlers::inconsistent_fulltext),
332 )
333}
334
335#[cfg(not(feature = "fulltext"))]
336fn add_fulltext_routes<
337 S: crate::storage::StorageClient
338 + crate::storage::StorageSchemaContextOps
339 + crate::storage::StorageSyncContextOps
340 + crate::storage::StorageOperationContextOps
341 + Clone
342 + Send
343 + Sync
344 + 'static,
345>(
346 router: Router<super::state::AppState<S>>,
347) -> Router<super::state::AppState<S>> {
348 router
349}
350
351#[cfg(feature = "vector")]
353fn add_vector_routes<
354 S: crate::storage::StorageClient
355 + crate::storage::StorageSchemaContextOps
356 + crate::storage::StorageSyncContextOps
357 + crate::storage::StorageOperationContextOps
358 + Clone
359 + Send
360 + Sync
361 + 'static,
362>(
363 router: Router<super::state::AppState<S>>,
364) -> Router<super::state::AppState<S>> {
365 router
366 .route(
367 "/vector/indexes",
368 post(vector::create_index).get(vector::list_indexes),
369 )
370 .route(
371 "/vector/indexes/{space_id}/{tag_name}/{field_name}",
372 get(vector::get_index_info).delete(vector::drop_index),
373 )
374 .route("/vector/search", post(vector::search))
375 .route(
376 "/vector/{space_id}/{tag_name}/{field_name}/{point_id}",
377 get(vector::get_vector),
378 )
379 .route(
380 "/vector/{space_id}/{tag_name}/{field_name}/count",
381 get(vector::count),
382 )
383 .route("/vector/payload", axum::routing::put(vector::set_payload))
384 .route(
385 "/vector/payload/fields",
386 axum::routing::put(vector::set_payload_fields),
387 )
388 .route("/vector/payload/delete", post(vector::delete_payload))
389 .route("/vector/scroll", post(vector::scroll))
390 .route(
391 "/vector/indexes/rebuild",
392 post(super::handlers::rebuild::rebuild_vector),
393 )
394 .route(
395 "/vector/rebuilds/{id}",
396 get(super::handlers::rebuild::vector_rebuild_status),
397 )
398 .route(
399 "/vector/indexes/clear",
400 post(super::handlers::rebuild::clear_vector),
401 )
402}
403
404#[cfg(not(feature = "vector"))]
405fn add_vector_routes<
406 S: crate::storage::StorageClient
407 + crate::storage::StorageSchemaContextOps
408 + crate::storage::StorageSyncContextOps
409 + crate::storage::StorageOperationContextOps
410 + Clone
411 + Send
412 + Sync
413 + 'static,
414>(
415 router: Router<super::state::AppState<S>>,
416) -> Router<super::state::AppState<S>> {
417 router
418}
419
420fn create_cors_layer(config: &crate::config::HttpServerConfig) -> Option<CorsLayer> {
428 use tower_http::cors::{AllowOrigin, Any};
429
430 if !config.cors_enabled {
431 return None;
432 }
433 if config.cors_allowed_origins.is_empty() {
434 log::warn!(
435 "CORS running in permissive mode (any origin); set server.http.cors_allowed_origins for production"
436 );
437 return Some(
438 CorsLayer::new()
439 .allow_origin(Any)
440 .allow_methods(Any)
441 .allow_headers(Any),
442 );
443 }
444 let origins: Vec<HeaderValue> = config
445 .cors_allowed_origins
446 .iter()
447 .filter_map(|origin| match origin.parse() {
448 Ok(value) => Some(value),
449 Err(e) => {
450 log::warn!("Skipping invalid CORS origin '{}': {}", origin, e);
451 None
452 }
453 })
454 .collect();
455 log::info!(
456 "CORS running in strict mode for {} origin(s)",
457 origins.len()
458 );
459 Some(
460 CorsLayer::new()
461 .allow_origin(AllowOrigin::list(origins))
462 .allow_methods([
463 Method::GET,
464 Method::POST,
465 Method::PUT,
466 Method::DELETE,
467 Method::OPTIONS,
468 ])
469 .allow_headers([
470 axum::http::header::CONTENT_TYPE,
471 axum::http::header::AUTHORIZATION,
472 axum::http::header::HeaderName::from_static("x-session-id"),
473 ])
474 .max_age(Duration::from_secs(600)),
475 )
476}