use std::sync::Arc;
use axum::extract::State;
use axum::routing::{delete, post};
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::api::auth::{generate_api_key, hash_api_key, key_prefix};
use crate::api::workflows::AppError;
use crate::api::AppState;
use crate::store::{ApiKeyRecord, WorkflowStore};
pub fn router<S: WorkflowStore + 'static>() -> Router<Arc<AppState<S>>> {
Router::new()
.route("/api-keys", post(create_api_key).get(list_api_keys))
.route("/api-keys/{prefix}", delete(revoke_api_key))
}
#[derive(Deserialize, ToSchema)]
pub struct CreateApiKeyRequest {
#[serde(default)]
pub label: Option<String>,
#[serde(default)]
pub idempotent: bool,
}
#[derive(Serialize, ToSchema)]
pub struct CreateApiKeyResponse {
#[serde(skip_serializing_if = "Option::is_none")]
pub plaintext: Option<String>,
pub prefix: String,
pub label: Option<String>,
pub created_at: f64,
}
#[utoipa::path(
post, path = "/api/v1/api-keys",
tag = "api-keys",
request_body = CreateApiKeyRequest,
responses(
(status = 201, description = "New API key minted", body = CreateApiKeyResponse),
(status = 200, description = "Idempotent: existing key with this label returned (no plaintext)", body = CreateApiKeyResponse),
(status = 500, description = "Internal error"),
),
)]
pub async fn create_api_key<S: WorkflowStore>(
State(state): State<Arc<AppState<S>>>,
Json(req): Json<CreateApiKeyRequest>,
) -> Result<(axum::http::StatusCode, Json<CreateApiKeyResponse>), AppError> {
if req.idempotent
&& let Some(label) = req.label.as_deref()
&& let Some(existing) = state.engine.store().get_api_key_by_label(label).await?
{
return Ok((
axum::http::StatusCode::OK,
Json(CreateApiKeyResponse {
plaintext: None,
prefix: existing.prefix,
label: existing.label,
created_at: existing.created_at,
}),
));
}
let plaintext = generate_api_key();
let hash = hash_api_key(&plaintext);
let prefix = key_prefix(&plaintext);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs_f64())
.unwrap_or(0.0);
state
.engine
.store()
.create_api_key(&hash, &prefix, req.label.as_deref(), now)
.await?;
Ok((
axum::http::StatusCode::CREATED,
Json(CreateApiKeyResponse {
plaintext: Some(plaintext),
prefix,
label: req.label,
created_at: now,
}),
))
}
#[utoipa::path(
get, path = "/api/v1/api-keys",
tag = "api-keys",
responses(
(status = 200, description = "List of API key metadata (hashes never exposed)", body = Vec<ApiKeyRecord>),
),
)]
pub async fn list_api_keys<S: WorkflowStore>(
State(state): State<Arc<AppState<S>>>,
) -> Result<Json<Vec<ApiKeyRecord>>, AppError> {
let keys = state.engine.store().list_api_keys().await?;
Ok(Json(keys))
}
#[utoipa::path(
delete, path = "/api/v1/api-keys/{prefix}",
tag = "api-keys",
params(("prefix" = String, Path, description = "Key prefix (e.g. assay_abcd1234...)")),
responses(
(status = 204, description = "Key revoked"),
(status = 404, description = "No key with that prefix"),
),
)]
pub async fn revoke_api_key<S: WorkflowStore>(
State(state): State<Arc<AppState<S>>>,
axum::extract::Path(prefix): axum::extract::Path<String>,
) -> Result<axum::http::StatusCode, AppError> {
let removed = state.engine.store().revoke_api_key(&prefix).await?;
if removed {
Ok(axum::http::StatusCode::NO_CONTENT)
} else {
Ok(axum::http::StatusCode::NOT_FOUND)
}
}