use axum::extract::{Extension, Path, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::routing::{delete, post};
use axum::{Json, Router};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use systemprompt_identifiers::{ApiKeyId, UserId};
use systemprompt_models::RequestContext;
use systemprompt_runtime::AppContext;
use systemprompt_users::{ApiKeyService, IssueApiKeyParams, UserApiKey};
use crate::error::ApiHttpError;
pub(super) fn router() -> Router<AppContext> {
Router::new()
.route("/", post(issue_key).get(list_keys))
.route("/{key_id}", delete(revoke_key))
}
#[derive(Debug, Deserialize)]
pub(super) struct IssueApiKeyRequest {
pub name: String,
#[serde(default)]
pub target_user_id: Option<String>,
#[serde(default)]
pub expires_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Serialize)]
pub(super) struct IssueApiKeyResponse {
pub id: String,
pub name: String,
pub key_prefix: String,
pub secret: String,
pub created_at: Option<DateTime<Utc>>,
pub expires_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Serialize)]
pub(super) struct ApiKeyView {
pub id: String,
pub name: String,
pub key_prefix: String,
pub created_at: Option<DateTime<Utc>>,
pub last_used_at: Option<DateTime<Utc>>,
pub expires_at: Option<DateTime<Utc>>,
pub revoked_at: Option<DateTime<Utc>>,
}
impl From<UserApiKey> for ApiKeyView {
fn from(k: UserApiKey) -> Self {
Self {
id: k.id.as_str().to_owned(),
name: k.name,
key_prefix: k.key_prefix,
created_at: k.created_at,
last_used_at: k.last_used_at,
expires_at: k.expires_at,
revoked_at: k.revoked_at,
}
}
}
async fn issue_key(
State(ctx): State<AppContext>,
Extension(req_ctx): Extension<RequestContext>,
Json(body): Json<IssueApiKeyRequest>,
) -> Result<impl IntoResponse, ApiHttpError> {
let target_user = resolve_target_user(&req_ctx, body.target_user_id.as_deref());
let service = ApiKeyService::new(ctx.db_pool())?;
let issued = service
.issue(IssueApiKeyParams {
user_id: &target_user,
name: &body.name,
expires_at: body.expires_at,
})
.await?;
Ok((
StatusCode::CREATED,
Json(IssueApiKeyResponse {
id: issued.record.id.as_str().to_owned(),
name: issued.record.name,
key_prefix: issued.record.key_prefix,
secret: issued.secret,
created_at: issued.record.created_at,
expires_at: issued.record.expires_at,
}),
))
}
async fn list_keys(
State(ctx): State<AppContext>,
Extension(req_ctx): Extension<RequestContext>,
) -> Result<Json<Vec<ApiKeyView>>, ApiHttpError> {
let service = ApiKeyService::new(ctx.db_pool())?;
let keys = service.list_for_user(req_ctx.user_id()).await?;
Ok(Json(keys.into_iter().map(ApiKeyView::from).collect()))
}
async fn revoke_key(
State(ctx): State<AppContext>,
Extension(req_ctx): Extension<RequestContext>,
Path(key_id): Path<String>,
) -> Result<StatusCode, ApiHttpError> {
let service = ApiKeyService::new(ctx.db_pool())?;
let id = ApiKeyId::new(key_id);
let revoked = service.revoke(&id, req_ctx.user_id()).await?;
if revoked {
Ok(StatusCode::NO_CONTENT)
} else {
Err(ApiHttpError::not_found("API key not found"))
}
}
fn resolve_target_user(req_ctx: &RequestContext, override_user_id: Option<&str>) -> UserId {
match override_user_id {
Some(value) if !value.is_empty() => UserId::new(value.to_owned()),
_ => req_ctx.user_id().clone(),
}
}