Skip to main content

linkrs_server/http/
router.rs

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
46/// Creating a router
47///
48/// Routing structure:
49/// /v1/health – Health check (public)
50/// – /v1/metrics – Prometheus scrape endpoint (public)
51/// – /v1/auth/* – Related to authentication (public information)
52/// – /v1/sessions/* – Session management (authentication required)
53/// /v1/query – Execution of a query (authentication required)
54/// /v1/transactions/* – Transaction management (authentication required)
55/// – /v1/schema/* – Schema management (requires authentication)
56/// – /api/* – Web management APIs (authentication required)
57pub 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    // Public route (no authentication required)
75    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            // POST /sessions verifies credentials itself, so it lives in the
81            // public group — auth is enforced inside the handler, not the
82            // middleware. Sessions GET/DELETE stay protected.
83            "/sessions",
84            post(create_session),
85        );
86
87    // Routes that require authentication
88    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        // Batch operation of routes
141        .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        // Import/Export routes
147        .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        // Statistical information routing
151        .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        // Configure management routing.
164        .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        // Custom function routing
170        .route("/functions", post(register).get(list))
171        .route("/functions/{name}", get(function_info).delete(unregister))
172        // Streaming Query Routing
173        .route("/query/stream", post(execute_stream))
174        // Forward-only result cursor routing
175        .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        // Sync Management Routes
188        .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        // Schema Routes
198        .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        // Schema Versioning Routes
215        .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        // Migration routes
228        .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)) // Limit on the request body size: 10 MB
265        .with_state(state.clone());
266
267    // CORS is driven by the live HTTP config: disabled means no layer at
268    // all, an empty whitelist keeps the permissive local behavior with a
269    // warning, and a non-empty whitelist enforces strict origins.
270    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    // Add web management routes if web_router is provided
280    let mut router = if let Some(wr) = web_router {
281        router.nest("/api", wr)
282    } else {
283        router
284    };
285
286    // Debug-only self-hosted OpenAPI document; excluded from the contract.
287    if cfg!(debug_assertions) {
288        router = router.route("/api-docs/openapi.json", get(serve_openapi));
289    }
290
291    router
292}
293
294/// Serve the aggregated OpenAPI document in debug builds.
295async fn serve_openapi() -> ([(&'static str, &'static str); 1], String) {
296    (
297        [("Content-Type", "application/json")],
298        super::openapi::openapi_json(),
299    )
300}
301
302/// Conditionally add fulltext rebuild/clear routes
303#[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/// Conditionally add vector search routes
352#[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
420/// Create a CORS configuration layer from the live HTTP config.
421///
422/// Returns `None` when CORS is disabled. An enabled switch with an empty
423/// whitelist keeps the permissive local-development combination and logs a
424/// warning; production must set exact origins to get the strict layer.
425/// The session id travels in the `X-Session-ID` header rather than a
426/// cookie, so credential mode stays off and never conflicts with origins.
427fn 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}