diff --git a/Cargo.lock b/Cargo.lock index 86437698..d810614e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -912,6 +912,8 @@ dependencies = [ "tower 0.5.3", "tracing", "tracing-subscriber", + "utoipa", + "utoipa-scalar", "uuid", "vergen-gitcl", ] @@ -4125,6 +4127,43 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "utoipa" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160" +dependencies = [ + "indexmap 2.13.0", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn", + "uuid", +] + +[[package]] +name = "utoipa-scalar" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59559e1509172f6b26c1cdbc7247c4ddd1ac6560fe94b584f81ee489b141f719" +dependencies = [ + "axum 0.8.8", + "serde", + "serde_json", + "utoipa", +] + [[package]] name = "uuid" version = "1.22.0" diff --git a/Cargo.toml b/Cargo.toml index 26c81861..9a9f815a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,8 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } unicode-normalization = { version = "0.1" } unicode-script = { version = "0.5" } unicode-segmentation = { version = "1.12" } +utoipa = { version = "5.5", features = ["axum_extras", "time", "uuid"] } +utoipa-scalar = { version = "0.3", features = ["axum"] } uuid = { version = "1.22", features = ["serde", "v4", "v5"] } vergen-gitcl = { version = "9.1", features = ["cargo"] } whatlang = { version = "0.18" } diff --git a/apps/elf-api/Cargo.toml b/apps/elf-api/Cargo.toml index fe5685ef..6c198ff2 100644 --- a/apps/elf-api/Cargo.toml +++ b/apps/elf-api/Cargo.toml @@ -14,6 +14,8 @@ time = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } +utoipa = { workspace = true } +utoipa-scalar = { workspace = true } uuid = { workspace = true } elf-cli = { workspace = true } diff --git a/apps/elf-api/src/routes.rs b/apps/elf-api/src/routes.rs index ba37546d..0afc91b9 100644 --- a/apps/elf-api/src/routes.rs +++ b/apps/elf-api/src/routes.rs @@ -8,7 +8,7 @@ use axum::{ rejection::{JsonRejection, QueryRejection}, }, http::{ - HeaderMap, Request, StatusCode, + HeaderMap, HeaderValue, Request, StatusCode, header::{CONTENT_LENGTH, CONTENT_TYPE}, }, middleware::{self, Next}, @@ -18,6 +18,8 @@ use axum::{ use serde::{Deserialize, Serialize}; use serde_json::Value; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; +use utoipa::{OpenApi, ToSchema}; +use utoipa_scalar::{Scalar, Servable}; use uuid::Uuid; use crate::state::AppState; @@ -49,6 +51,11 @@ use elf_service::{ UpdateResponse, search::TraceBundleMode, }; +/// JSON OpenAPI contract route. +pub const OPENAPI_JSON_PATH: &str = "/openapi.json"; +/// Scalar API reference route. +pub const SCALAR_DOCS_PATH: &str = "/docs"; + const HEADER_TENANT_ID: &str = "X-ELF-Tenant-Id"; const HEADER_PROJECT_ID: &str = "X-ELF-Project-Id"; const HEADER_AGENT_ID: &str = "X-ELF-Agent-Id"; @@ -69,6 +76,72 @@ const MAX_TOP_K: u32 = 100; const MAX_CANDIDATE_K: u32 = 1_000; const MAX_ERROR_LOG_CHARS: usize = 1_024; +/// Generated OpenAPI document for the ELF HTTP API. +#[derive(OpenApi)] +#[openapi( + info( + title = "ELF API", + version = env!("CARGO_PKG_VERSION"), + description = "Evidence-linked fact memory HTTP and admin API." + ), + paths( + health, + notes_ingest, + events_ingest, + docs_put, + docs_get, + docs_search_l0, + docs_excerpts_get, + graph_query, + searches_create, + searches_get, + searches_timeline, + searches_notes, + notes_list, + notes_get, + notes_patch, + notes_delete, + notes_publish, + notes_unpublish, + space_grants_list, + space_grant_upsert, + space_grant_revoke, + admin_ingestion_profiles_list, + admin_ingestion_profile_create, + admin_ingestion_profile_get, + admin_ingestion_profile_versions_list, + admin_ingestion_profile_default_get, + admin_ingestion_profile_default_set, + rebuild_qdrant, + searches_raw, + trace_recent_list, + trace_get, + trace_bundle_get, + trace_trajectory_get, + trace_item_get, + admin_graph_predicates_list, + admin_graph_predicate_patch, + admin_graph_predicate_alias_add, + admin_graph_predicate_aliases_list, + admin_note_provenance_get, + ), + components(schemas( + AdminIngestionProfileDefaultResponseV2, + AdminIngestionProfileDefaultSetBody, + ErrorBody, + )), + tags( + (name = "health", description = "Health and process liveness."), + (name = "notes", description = "Memory note ingestion, listing, mutation, and sharing."), + (name = "events", description = "Event ingestion through the extractor pipeline."), + (name = "docs", description = "Document extension ingestion, search, and excerpt retrieval."), + (name = "search", description = "Progressive search sessions and raw search diagnostics."), + (name = "graph", description = "Graph query and predicate administration."), + (name = "admin", description = "Local admin and operator inspection routes."), + ) +)] +pub struct ApiDoc; + #[derive(Clone, Debug)] struct RequestContext { tenant_id: String, @@ -163,13 +236,6 @@ struct SearchCreateRequest { ranking: Option, } -#[derive(Clone, Copy, Debug, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -enum SearchMode { - QuickFind, - PlannedSearch, -} - #[derive(Clone, Debug, Serialize)] struct SearchIndexResponseV2 { mode: SearchMode, @@ -239,12 +305,19 @@ struct AdminIngestionProfileGetQuery { version: Option, } -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, ToSchema)] struct AdminIngestionProfileDefaultSetBody { profile_id: String, version: Option, } +#[derive(Clone, Debug, Serialize, ToSchema)] +struct AdminIngestionProfileDefaultResponseV2 { + profile_id: String, + version: Option, + updated_at: String, +} + #[derive(Clone, Debug, Serialize)] struct SearchDetailsResponseV2 { search_id: Uuid, @@ -341,7 +414,7 @@ struct SpaceGrantsListResponseV2 { grants: Vec, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, ToSchema)] struct ErrorBody { error_code: String, message: String, @@ -432,6 +505,13 @@ impl IntoResponse for ApiError { } } +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +enum SearchMode { + QuickFind, + PlannedSearch, +} + /// Builds the authenticated public API router. pub fn router(state: AppState) -> Router { let auth_state = state.clone(); @@ -467,6 +547,7 @@ pub fn router(state: AppState) -> Router { .layer(DefaultBodyLimit::max(MAX_DOC_REQUEST_BYTES)); Router::new() + .merge(contract_router()) .merge(api_router) .merge(docs_router) .layer(middleware::from_fn_with_state(auth_state, api_auth_middleware)) @@ -516,6 +597,16 @@ pub fn admin_router(state: AppState) -> Router { .layer(middleware::from_fn_with_state(auth_state, admin_auth_middleware)) } +/// Builds the API contract router. +pub fn contract_router() -> Router +where + S: Clone + Send + Sync + 'static, +{ + Router::new() + .route(OPENAPI_JSON_PATH, routing::get(openapi_json)) + .merge(Scalar::with_url(SCALAR_DOCS_PATH, ::openapi())) +} + fn json_error( status: StatusCode, code: &str, @@ -814,6 +905,16 @@ fn parse_optional_rfc3339( }) } +async fn openapi_json() -> Response { + let mut response = Json(::openapi()).into_response(); + + response + .headers_mut() + .insert(CONTENT_TYPE, HeaderValue::from_static("application/vnd.oai.openapi+json")); + + response +} + async fn with_request_id(response: Response, request_id: Uuid) -> Response { let (mut parts, body) = response.into_parts(); @@ -940,10 +1041,30 @@ async fn admin_auth_middleware( with_request_id(response, request_id).await } +#[utoipa::path( + get, + path = "/health", + tag = "health", + responses((status = 200, description = "API process is healthy.")) +)] async fn health() -> StatusCode { StatusCode::OK } +#[utoipa::path( + post, + path = "/v2/notes/ingest", + tag = "notes", + request_body = Value, + responses( + (status = 200, description = "Notes were processed.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 422, description = "Non-English input rejected.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] async fn notes_ingest( State(state): State, headers: HeaderMap, @@ -984,6 +1105,20 @@ async fn notes_ingest( Ok(Json(response)) } +#[utoipa::path( + post, + path = "/v2/events/ingest", + tag = "events", + request_body = Value, + responses( + (status = 200, description = "Event messages were processed.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 422, description = "Non-English input rejected.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] async fn events_ingest( State(state): State, headers: HeaderMap, @@ -1037,6 +1172,20 @@ async fn events_ingest( Ok(Json(response)) } +#[utoipa::path( + post, + path = "/v2/docs", + tag = "docs", + request_body = Value, + responses( + (status = 200, description = "Document was stored.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 422, description = "Non-English input rejected.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] async fn docs_put( State(state): State, headers: HeaderMap, @@ -1073,6 +1222,20 @@ async fn docs_put( Ok(Json(response)) } +#[utoipa::path( + get, + path = "/v2/docs/{doc_id}", + tag = "docs", + params(("doc_id" = Uuid, Path, description = "Document ID.")), + responses( + (status = 200, description = "Document was fetched.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 404, description = "Document was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] async fn docs_get( State(state): State, headers: HeaderMap, @@ -1094,6 +1257,20 @@ async fn docs_get( Ok(Json(response)) } +#[utoipa::path( + post, + path = "/v2/docs/search/l0", + tag = "docs", + request_body = Value, + responses( + (status = 200, description = "L0 document search results.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 422, description = "Non-English input rejected.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] async fn docs_search_l0( State(state): State, headers: HeaderMap, @@ -1188,6 +1365,20 @@ async fn docs_search_l0( Ok(Json(response)) } +#[utoipa::path( + post, + path = "/v2/docs/excerpts", + tag = "docs", + request_body = Value, + responses( + (status = 200, description = "Document excerpt result.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 404, description = "Document or excerpt was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] async fn docs_excerpts_get( State(state): State, headers: HeaderMap, @@ -1219,6 +1410,20 @@ async fn docs_excerpts_get( Ok(Json(response)) } +#[utoipa::path( + post, + path = "/v2/graph/query", + tag = "graph", + request_body = Value, + responses( + (status = 200, description = "Graph facts matching the query.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 422, description = "Non-English input rejected.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] async fn graph_query( State(state): State, headers: HeaderMap, @@ -1251,6 +1456,20 @@ async fn graph_query( Ok(Json(response)) } +#[utoipa::path( + post, + path = "/v2/searches", + tag = "search", + request_body = Value, + responses( + (status = 200, description = "Search session was created.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 422, description = "Non-English input rejected.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] async fn searches_create( State(state): State, headers: HeaderMap, @@ -1345,6 +1564,25 @@ async fn searches_create( Ok(Json(response)) } +#[utoipa::path( + get, + path = "/v2/searches/{search_id}", + tag = "search", + params( + ("search_id" = Uuid, Path, description = "Search session ID."), + ("payload_level" = Option, Query, description = "Optional payload level."), + ("top_k" = Option, Query, description = "Optional result limit override."), + ("touch" = Option, Query, description = "Whether to extend the session TTL."), + ), + responses( + (status = 200, description = "Search session index view.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 404, description = "Search session was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] async fn searches_get( State(state): State, headers: HeaderMap, @@ -1391,6 +1629,24 @@ async fn searches_get( })) } +#[utoipa::path( + get, + path = "/v2/searches/{search_id}/timeline", + tag = "search", + params( + ("search_id" = Uuid, Path, description = "Search session ID."), + ("payload_level" = Option, Query, description = "Optional payload level."), + ("group_by" = Option, Query, description = "Timeline grouping mode."), + ), + responses( + (status = 200, description = "Search session timeline.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 404, description = "Search session was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] async fn searches_timeline( State(state): State, headers: HeaderMap, @@ -1427,6 +1683,21 @@ async fn searches_timeline( })) } +#[utoipa::path( + post, + path = "/v2/searches/{search_id}/notes", + tag = "search", + params(("search_id" = Uuid, Path, description = "Search session ID.")), + request_body = Value, + responses( + (status = 200, description = "Hydrated search note details.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 404, description = "Search session was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] async fn searches_notes( State(state): State, headers: HeaderMap, @@ -1469,6 +1740,23 @@ async fn searches_notes( })) } +#[utoipa::path( + get, + path = "/v2/notes", + tag = "notes", + params( + ("scope" = Option, Query, description = "Optional note scope filter."), + ("status" = Option, Query, description = "Optional note status filter."), + ("type" = Option, Query, description = "Optional note type filter."), + ), + responses( + (status = 200, description = "Notes visible to the caller.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] async fn notes_list( State(state): State, headers: HeaderMap, @@ -1500,6 +1788,20 @@ async fn notes_list( Ok(Json(response)) } +#[utoipa::path( + get, + path = "/v2/notes/{note_id}", + tag = "notes", + params(("note_id" = Uuid, Path, description = "Note ID.")), + responses( + (status = 200, description = "Note details.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 404, description = "Note was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] async fn notes_get( State(state): State, headers: HeaderMap, @@ -1519,6 +1821,22 @@ async fn notes_get( Ok(Json(response)) } +#[utoipa::path( + patch, + path = "/v2/notes/{note_id}", + tag = "notes", + params(("note_id" = Uuid, Path, description = "Note ID.")), + request_body = Value, + responses( + (status = 200, description = "Note was updated.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 404, description = "Note was not found.", body = ErrorBody), + (status = 422, description = "Non-English input rejected.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] async fn notes_patch( State(state): State, headers: HeaderMap, @@ -1548,6 +1866,20 @@ async fn notes_patch( Ok(Json(response)) } +#[utoipa::path( + delete, + path = "/v2/notes/{note_id}", + tag = "notes", + params(("note_id" = Uuid, Path, description = "Note ID.")), + responses( + (status = 200, description = "Note was deleted.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 404, description = "Note was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] async fn notes_delete( State(state): State, headers: HeaderMap, @@ -1567,6 +1899,21 @@ async fn notes_delete( Ok(Json(response)) } +#[utoipa::path( + post, + path = "/v2/notes/{note_id}/publish", + tag = "notes", + params(("note_id" = Uuid, Path, description = "Note ID.")), + request_body = Value, + responses( + (status = 200, description = "Note was published to a shared space.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 404, description = "Note was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] async fn notes_publish( State(state): State, headers: HeaderMap, @@ -1604,6 +1951,21 @@ async fn notes_publish( })) } +#[utoipa::path( + post, + path = "/v2/notes/{note_id}/unpublish", + tag = "notes", + params(("note_id" = Uuid, Path, description = "Note ID.")), + request_body = Value, + responses( + (status = 200, description = "Note was returned to private scope.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 404, description = "Note was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] async fn notes_unpublish( State(state): State, headers: HeaderMap, @@ -1640,6 +2002,19 @@ async fn notes_unpublish( })) } +#[utoipa::path( + get, + path = "/v2/spaces/{space}/grants", + tag = "notes", + params(("space" = String, Path, description = "Shared space name.")), + responses( + (status = 200, description = "Space grants.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] async fn space_grants_list( State(state): State, headers: HeaderMap, @@ -1672,6 +2047,20 @@ async fn space_grants_list( })) } +#[utoipa::path( + post, + path = "/v2/spaces/{space}/grants", + tag = "notes", + params(("space" = String, Path, description = "Shared space name.")), + request_body = Value, + responses( + (status = 200, description = "Space grant was upserted.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] async fn space_grant_upsert( State(state): State, headers: HeaderMap, @@ -1712,6 +2101,20 @@ async fn space_grant_upsert( })) } +#[utoipa::path( + post, + path = "/v2/spaces/{space}/grants/revoke", + tag = "notes", + params(("space" = String, Path, description = "Shared space name.")), + request_body = Value, + responses( + (status = 200, description = "Space grant was revoked.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] async fn space_grant_revoke( State(state): State, headers: HeaderMap, @@ -1747,6 +2150,19 @@ async fn space_grant_revoke( Ok(Json(response)) } +#[utoipa::path( + get, + path = "/v2/admin/graph/predicates", + tag = "graph", + params(("scope" = Option, Query, description = "Predicate scope filter.")), + responses( + (status = 200, description = "Graph predicates.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] async fn admin_graph_predicates_list( State(state): State, headers: HeaderMap, @@ -1776,6 +2192,22 @@ async fn admin_graph_predicates_list( Ok(Json(response)) } +#[utoipa::path( + patch, + path = "/v2/admin/graph/predicates/{predicate_id}", + tag = "graph", + params(("predicate_id" = Uuid, Path, description = "Predicate ID.")), + request_body = Value, + responses( + (status = 200, description = "Graph predicate was updated.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 404, description = "Predicate was not found.", body = ErrorBody), + (status = 409, description = "Predicate update conflicted.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] async fn admin_graph_predicate_patch( State(state): State, headers: HeaderMap, @@ -1805,6 +2237,22 @@ async fn admin_graph_predicate_patch( Ok(Json(response)) } +#[utoipa::path( + post, + path = "/v2/admin/graph/predicates/{predicate_id}/aliases", + tag = "graph", + params(("predicate_id" = Uuid, Path, description = "Predicate ID.")), + request_body = Value, + responses( + (status = 200, description = "Graph predicate alias was added.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 404, description = "Predicate was not found.", body = ErrorBody), + (status = 409, description = "Predicate update conflicted.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] async fn admin_graph_predicate_alias_add( State(state): State, headers: HeaderMap, @@ -1833,6 +2281,20 @@ async fn admin_graph_predicate_alias_add( Ok(Json(response)) } +#[utoipa::path( + get, + path = "/v2/admin/graph/predicates/{predicate_id}/aliases", + tag = "graph", + params(("predicate_id" = Uuid, Path, description = "Predicate ID.")), + responses( + (status = 200, description = "Graph predicate aliases.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 404, description = "Predicate was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] async fn admin_graph_predicate_aliases_list( State(state): State, headers: HeaderMap, @@ -1852,6 +2314,20 @@ async fn admin_graph_predicate_aliases_list( Ok(Json(response)) } +#[utoipa::path( + get, + path = "/v2/admin/notes/{note_id}/provenance", + tag = "admin", + params(("note_id" = Uuid, Path, description = "Note ID.")), + responses( + (status = 200, description = "Note provenance bundle.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 404, description = "Note was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] async fn admin_note_provenance_get( State(state): State, headers: HeaderMap, @@ -1870,6 +2346,18 @@ async fn admin_note_provenance_get( Ok(Json(response)) } +#[utoipa::path( + get, + path = "/v2/admin/events/ingestion-profiles", + tag = "admin", + responses( + (status = 200, description = "Ingestion profile versions.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] async fn admin_ingestion_profiles_list( State(state): State, headers: HeaderMap, @@ -1886,6 +2374,19 @@ async fn admin_ingestion_profiles_list( Ok(Json(response)) } +#[utoipa::path( + post, + path = "/v2/admin/events/ingestion-profiles", + tag = "admin", + request_body = Value, + responses( + (status = 200, description = "Ingestion profile version was created.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] async fn admin_ingestion_profile_create( State(state): State, headers: HeaderMap, @@ -1912,6 +2413,23 @@ async fn admin_ingestion_profile_create( Ok(Json(response)) } +#[utoipa::path( + get, + path = "/v2/admin/events/ingestion-profiles/{profile_id}", + tag = "admin", + params( + ("profile_id" = String, Path, description = "Ingestion profile ID."), + ("version" = Option, Query, description = "Optional profile version."), + ), + responses( + (status = 200, description = "Ingestion profile version.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 404, description = "Profile was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] async fn admin_ingestion_profile_get( State(state): State, headers: HeaderMap, @@ -1942,6 +2460,19 @@ async fn admin_ingestion_profile_get( Ok(Json(response)) } +#[utoipa::path( + get, + path = "/v2/admin/events/ingestion-profiles/{profile_id}/versions", + tag = "admin", + params(("profile_id" = String, Path, description = "Ingestion profile ID.")), + responses( + (status = 200, description = "Versions for one ingestion profile.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] async fn admin_ingestion_profile_versions_list( State(state): State, headers: HeaderMap, @@ -1960,6 +2491,22 @@ async fn admin_ingestion_profile_versions_list( Ok(Json(response)) } +#[utoipa::path( + get, + path = "/v2/admin/events/ingestion-profiles/default", + tag = "admin", + responses( + ( + status = 200, + description = "Default add_event ingestion profile pointer.", + body = AdminIngestionProfileDefaultResponseV2, + ), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] async fn admin_ingestion_profile_default_get( State(state): State, headers: HeaderMap, @@ -1976,6 +2523,24 @@ async fn admin_ingestion_profile_default_get( Ok(Json(response)) } +#[utoipa::path( + put, + path = "/v2/admin/events/ingestion-profiles/default", + tag = "admin", + request_body = AdminIngestionProfileDefaultSetBody, + responses( + ( + status = 200, + description = "Default add_event ingestion profile pointer was updated.", + body = AdminIngestionProfileDefaultResponseV2, + ), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 404, description = "Profile was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] async fn admin_ingestion_profile_default_set( State(state): State, headers: HeaderMap, @@ -2000,12 +2565,37 @@ async fn admin_ingestion_profile_default_set( Ok(Json(response)) } +#[utoipa::path( + post, + path = "/v2/admin/qdrant/rebuild", + tag = "admin", + responses( + (status = 200, description = "Qdrant rebuild report.", body = Value), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] async fn rebuild_qdrant(State(state): State) -> Result, ApiError> { let response = state.service.rebuild_qdrant().await?; Ok(Json(response)) } +#[utoipa::path( + post, + path = "/v2/admin/searches/raw", + tag = "search", + request_body = Value, + responses( + (status = 200, description = "Raw admin search response.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 422, description = "Non-English input rejected.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] async fn searches_raw( State(state): State, headers: HeaderMap, @@ -2074,6 +2664,20 @@ async fn searches_raw( Ok(Json(response)) } +#[utoipa::path( + get, + path = "/v2/admin/traces/{trace_id}", + tag = "admin", + params(("trace_id" = Uuid, Path, description = "Search trace ID.")), + responses( + (status = 200, description = "Search trace bundle without full stage internals.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 404, description = "Trace was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] async fn trace_get( State(state): State, headers: HeaderMap, @@ -2093,6 +2697,27 @@ async fn trace_get( Ok(Json(response)) } +#[utoipa::path( + get, + path = "/v2/admin/traces/recent", + tag = "admin", + params( + ("limit" = Option, Query, description = "Page size."), + ("cursor_created_at" = Option, Query, description = "Created-at page cursor."), + ("cursor_trace_id" = Option, Query, description = "Trace ID page cursor."), + ("agent_id" = Option, Query, description = "Optional trace creator filter."), + ("read_profile" = Option, Query, description = "Optional read profile filter."), + ("created_after" = Option, Query, description = "Strict lower created_at bound."), + ("created_before" = Option, Query, description = "Strict upper created_at bound."), + ), + responses( + (status = 200, description = "Recent search traces.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] async fn trace_recent_list( State(state): State, headers: HeaderMap, @@ -2143,6 +2768,20 @@ async fn trace_recent_list( Ok(Json(response)) } +#[utoipa::path( + get, + path = "/v2/admin/trajectories/{trace_id}", + tag = "admin", + params(("trace_id" = Uuid, Path, description = "Search trace ID.")), + responses( + (status = 200, description = "Search trace retrieval trajectory.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 404, description = "Trace was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] async fn trace_trajectory_get( State(state): State, headers: HeaderMap, @@ -2162,6 +2801,20 @@ async fn trace_trajectory_get( Ok(Json(response)) } +#[utoipa::path( + get, + path = "/v2/admin/trace-items/{item_id}", + tag = "admin", + params(("item_id" = Uuid, Path, description = "Trace item/result handle ID.")), + responses( + (status = 200, description = "Search trace item explain payload.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 404, description = "Trace item was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] async fn trace_item_get( State(state): State, headers: HeaderMap, @@ -2181,6 +2834,25 @@ async fn trace_item_get( Ok(Json(response)) } +#[utoipa::path( + get, + path = "/v2/admin/traces/{trace_id}/bundle", + tag = "admin", + params( + ("trace_id" = Uuid, Path, description = "Search trace ID."), + ("mode" = Option, Query, description = "bounded or full."), + ("stage_items_limit" = Option, Query, description = "Maximum stage items."), + ("candidates_limit" = Option, Query, description = "Maximum candidate snapshot items."), + ), + responses( + (status = 200, description = "Search trace bundle.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 404, description = "Trace was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] async fn trace_bundle_get( State(state): State, headers: HeaderMap, diff --git a/apps/elf-api/tests/http.rs b/apps/elf-api/tests/http.rs index fc0d307f..45c06f94 100644 --- a/apps/elf-api/tests/http.rs +++ b/apps/elf-api/tests/http.rs @@ -18,7 +18,10 @@ use tower::util::ServiceExt as _; use tracing::Level; use uuid::Uuid; -use elf_api::{routes, state::AppState}; +use elf_api::{ + routes::{self, OPENAPI_JSON_PATH, SCALAR_DOCS_PATH}, + state::AppState, +}; use elf_config::{ Chunking, Config, EmbeddingProviderConfig, Lifecycle, LlmProviderConfig, Memory, Postgres, ProviderConfig, Providers, Qdrant, Ranking, RankingBlend, RankingBlendSegment, @@ -92,7 +95,7 @@ fn test_config(dsn: String, qdrant_url: String, collection: String) -> Config { log_level: "info".to_string(), }, storage: Storage { - postgres: Postgres { dsn, pool_max_conns: 1 }, + postgres: Postgres { dsn, pool_max_conns: 4 }, qdrant: Qdrant { url: qdrant_url, collection: collection.clone(), @@ -234,6 +237,15 @@ fn dummy_llm_provider() -> LlmProviderConfig { } } +fn assert_openapi_method(spec: &serde_json::Value, path: &str, method: &str) { + let operation = spec + .get("paths") + .and_then(|paths| paths.get(path)) + .and_then(|path_item| path_item.get(method)); + + assert!(operation.is_some(), "Missing OpenAPI operation {method} {path}"); +} + fn init_test_tracing() { let _ = tracing_subscriber::fmt().with_max_level(Level::ERROR).with_test_writer().try_init(); } @@ -708,12 +720,18 @@ async fn fetch_search_notes_for_payload_level( ) .await .expect("Failed to call search notes."); - - assert_eq!(response.status(), StatusCode::OK); - + let status = response.status(); let body = body::to_bytes(response.into_body(), usize::MAX) .await .expect("Failed to read search notes response body."); + + assert_eq!( + status, + StatusCode::OK, + "Unexpected search notes response: {}", + String::from_utf8_lossy(&body) + ); + let json: serde_json::Value = serde_json::from_slice(&body).expect("Failed to parse search notes response."); @@ -770,6 +788,106 @@ async fn fetch_admin_search_raw_source_ref( item["source_ref"].clone() } +async fn contract_json() -> serde_json::Value { + let app = routes::contract_router::<()>(); + let response = app + .oneshot( + Request::builder() + .uri(OPENAPI_JSON_PATH) + .body(Body::empty()) + .expect("Failed to build OpenAPI request."), + ) + .await + .expect("Failed to call OpenAPI route."); + + assert_eq!(response.status(), StatusCode::OK); + + let body = body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("Failed to read OpenAPI response body."); + + serde_json::from_slice(&body).expect("Failed to parse OpenAPI response.") +} + +#[tokio::test] +async fn openapi_json_route_serves_generated_contract() { + let spec = contract_json().await; + + assert_eq!(spec["info"]["title"], "ELF API"); + assert!(spec.get("request_id").is_none()); + + assert_openapi_method(&spec, "/health", "get"); + assert_openapi_method(&spec, "/v2/notes/ingest", "post"); + assert_openapi_method(&spec, "/v2/events/ingest", "post"); + assert_openapi_method(&spec, "/v2/docs/search/l0", "post"); + assert_openapi_method(&spec, "/v2/searches/{search_id}/notes", "post"); + assert_openapi_method(&spec, "/v2/admin/searches/raw", "post"); + assert_openapi_method(&spec, "/v2/admin/events/ingestion-profiles/default", "get"); + assert_openapi_method(&spec, "/v2/admin/events/ingestion-profiles/default", "put"); +} + +#[tokio::test] +async fn scalar_docs_route_serves_api_reference_html() { + let app = routes::contract_router::<()>(); + let response = app + .oneshot( + Request::builder() + .uri(SCALAR_DOCS_PATH) + .body(Body::empty()) + .expect("Failed to build Scalar docs request."), + ) + .await + .expect("Failed to call Scalar docs route."); + + assert_eq!(response.status(), StatusCode::OK); + + let body = body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("Failed to read Scalar docs response body."); + let html = String::from_utf8(body.to_vec()).expect("Scalar docs response was not UTF-8."); + + assert!(html.contains("@scalar/api-reference")); + assert!(html.contains("/v2/admin/events/ingestion-profiles/default")); +} + +#[tokio::test] +async fn openapi_includes_default_ingestion_profile_get_put_contract() { + let spec = contract_json().await; + let default_path = &spec["paths"]["/v2/admin/events/ingestion-profiles/default"]; + let get_schema_ref = + default_path["get"]["responses"]["200"]["content"]["application/json"]["schema"]["$ref"] + .as_str() + .expect("Missing default profile GET response schema ref."); + let put_request_schema_ref = default_path["put"]["requestBody"]["content"]["application/json"] + ["schema"]["$ref"] + .as_str() + .expect("Missing default profile PUT request schema ref."); + let put_response_schema_ref = + default_path["put"]["responses"]["200"]["content"]["application/json"]["schema"]["$ref"] + .as_str() + .expect("Missing default profile PUT response schema ref."); + + assert!(get_schema_ref.ends_with("/AdminIngestionProfileDefaultResponseV2")); + assert!(put_request_schema_ref.ends_with("/AdminIngestionProfileDefaultSetBody")); + assert!(put_response_schema_ref.ends_with("/AdminIngestionProfileDefaultResponseV2")); + + let schemas = &spec["components"]["schemas"]; + let request_schema = &schemas["AdminIngestionProfileDefaultSetBody"]; + let response_schema = &schemas["AdminIngestionProfileDefaultResponseV2"]; + + assert!(request_schema["properties"].get("profile_id").is_some()); + assert!(request_schema["properties"].get("version").is_some()); + assert!( + request_schema["required"] + .as_array() + .expect("Missing request required fields") + .contains(&serde_json::json!("profile_id")) + ); + assert!(response_schema["properties"].get("profile_id").is_some()); + assert!(response_schema["properties"].get("version").is_some()); + assert!(response_schema["properties"].get("updated_at").is_some()); +} + #[tokio::test] #[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] async fn sharing_visibility_requires_explicit_project_grant() { diff --git a/docs/guide/getting_started.md b/docs/guide/getting_started.md index 02614ad3..218fffcb 100644 --- a/docs/guide/getting_started.md +++ b/docs/guide/getting_started.md @@ -53,7 +53,22 @@ cargo run -p elf-api -- -c elf.toml cargo run -p elf-mcp -- -c elf.toml ``` -## 4. Run retrieval evaluation +## 4. Inspect API contract + +After `elf-api` starts, the API process serves: + +- `GET /openapi.json` for the generated OpenAPI contract. +- `GET /docs` for the Scalar API reference UI. + +Use the host and port from `service.http_bind` in `elf.toml`. +For example: + +```sh +curl -fsS http://127.0.0.1:51892/openapi.json +open http://127.0.0.1:51892/docs +``` + +## 5. Run retrieval evaluation Use `elf-eval` with your dataset. @@ -63,7 +78,7 @@ cargo run -p elf-eval -- -c elf.toml -i path/to/eval.json For dataset format and metric details, see `docs/guide/evaluation.md`. -## 5. Development workflow +## 6. Development workflow Use `cargo make` tasks from repository root.