Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 56 additions & 3 deletions crates/api/src/routes/workspaces.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,48 @@ impl From<WorkspaceOrderDirection> for services::workspace::WorkspaceOrderDirect
}
}

#[derive(Debug, Deserialize)]
pub struct ListApiKeysParams {
#[serde(default = "crate::routes::common::default_limit")]
pub limit: i64,
#[serde(default)]
pub offset: i64,
pub order_by: Option<ApiKeyOrderBy>,
pub order_direction: Option<ApiKeyOrderDirection>,
}

#[derive(Debug, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum ApiKeyOrderBy {
CreatedAt,
Usage,
}

impl From<ApiKeyOrderBy> for services::workspace::ApiKeyOrderBy {
fn from(value: ApiKeyOrderBy) -> Self {
match value {
ApiKeyOrderBy::CreatedAt => Self::CreatedAt,
ApiKeyOrderBy::Usage => Self::Usage,
}
}
}

#[derive(Debug, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum ApiKeyOrderDirection {
Asc,
Desc,
}

impl From<ApiKeyOrderDirection> for services::workspace::ApiKeyOrderDirection {
fn from(value: ApiKeyOrderDirection) -> Self {
match value {
ApiKeyOrderDirection::Asc => Self::Asc,
ApiKeyOrderDirection::Desc => Self::Desc,
}
}
}

// ============================================
// Workspace Management Routes
// ============================================
Expand Down Expand Up @@ -772,7 +814,9 @@ pub async fn create_workspace_api_key(
params(
("workspace_id" = Uuid, Path, description = "Workspace ID"),
("limit" = Option<i64>, Query, description = "Maximum number of results (default: 20)"),
("offset" = Option<i64>, Query, description = "Number of results to skip (default: 0)")
("offset" = Option<i64>, Query, description = "Number of results to skip (default: 0)"),
("order_by" = Option<ApiKeyOrderBy>, Query, description = "Field to order by: created_at or usage"),
("order_direction" = Option<ApiKeyOrderDirection>, Query, description = "Sort direction: asc or desc")
),
responses(
(status = 200, description = "Paginated list of workspace API keys", body = ListApiKeysResponse),
Expand All @@ -789,7 +833,7 @@ pub async fn list_workspace_api_keys(
State(app_state): State<AppState>,
Extension(user): Extension<AuthenticatedUser>,
Path(workspace_id): Path<Uuid>,
Query(params): Query<ListParams>,
Query(params): Query<ListApiKeysParams>,
) -> Result<Json<ListApiKeysResponse>, (StatusCode, Json<ErrorResponse>)> {
debug!(
"Listing API keys for workspace: {} by user: {} (limit: {}, offset: {})",
Expand Down Expand Up @@ -837,9 +881,18 @@ pub async fn list_workspace_api_keys(
};

// Use workspace service to list workspace API keys with pagination and usage data
let order_by = params.order_by.map(Into::into);
let order_direction = params.order_direction.map(Into::into);
match app_state
.workspace_service
.list_api_keys_paginated(workspace_id_typed, user_id, params.limit, params.offset)
.list_api_keys_paginated(
workspace_id_typed,
user_id,
params.limit,
params.offset,
order_by,
order_direction,
)
.await
{
Ok(api_keys) => {
Expand Down
91 changes: 91 additions & 0 deletions crates/api/tests/e2e_all/api_keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,97 @@ async fn test_list_workspace_api_keys() {
println!("Listed {} API keys successfully", api_keys.len());
}

#[tokio::test]
async fn test_list_workspace_api_keys_orders_by_usage() {
let (server, database) = setup_test_server_with_database().await;
let org = create_org(&server).await;
let workspaces = list_workspaces(&server, org.id.clone()).await;
let workspace = workspaces.first().unwrap();
let model_name = setup_qwen_model(&server).await;

let unused_key =
create_api_key_in_workspace(&server, workspace.id.clone(), "Unused Key".to_string()).await;
let low_spend_key =
create_api_key_in_workspace(&server, workspace.id.clone(), "Low Spend Key".to_string())
.await;
let high_spend_key =
create_api_key_in_workspace(&server, workspace.id.clone(), "High Spend Key".to_string())
.await;

let organization_id = uuid::Uuid::parse_str(&org.id).unwrap();
let workspace_id = uuid::Uuid::parse_str(&workspace.id).unwrap();
let low_spend_key_id = uuid::Uuid::parse_str(&low_spend_key.id).unwrap();
let high_spend_key_id = uuid::Uuid::parse_str(&high_spend_key.id).unwrap();
let client = database.pool().get().await.unwrap();
let model_id: uuid::Uuid = client
.query_one(
"SELECT id FROM models WHERE model_name = $1",
&[&model_name],
)
.await
.unwrap()
.get(0);

for (api_key_id, total_cost) in [
(low_spend_key_id, 100_000_000_i64),
(high_spend_key_id, 300_000_000_i64),
] {
client
.execute(
r#"
INSERT INTO organization_usage_log (
id, organization_id, workspace_id, api_key_id,
model_id, model_name, input_tokens, output_tokens,
total_tokens, input_cost, output_cost, total_cost,
inference_type, created_at
) VALUES ($1, $2, $3, $4, $5, $6, 10, 10, 20, 1, 1, $7,
'chat_completion', NOW())
"#,
&[
&uuid::Uuid::new_v4(),
&organization_id,
&workspace_id,
&api_key_id,
&model_id,
&model_name,
&total_cost,
],
)
.await
.unwrap();
}

let desc_response = server
.get(
format!(
"/v1/workspaces/{}/api-keys?limit=2&order_by=usage&order_direction=desc",
workspace.id
)
.as_str(),
)
.add_header("Authorization", format!("Bearer {}", get_session_id()))
.await;
assert_eq!(desc_response.status_code(), 200);
let desc_list = desc_response.json::<api::models::ListApiKeysResponse>();
assert_eq!(desc_list.api_keys[0].id, high_spend_key.id);
assert_eq!(desc_list.api_keys[1].id, low_spend_key.id);

let asc_response = server
.get(
format!(
"/v1/workspaces/{}/api-keys?limit=2&order_by=usage&order_direction=asc",
workspace.id
)
.as_str(),
)
.add_header("Authorization", format!("Bearer {}", get_session_id()))
.await;
assert_eq!(asc_response.status_code(), 200);
let asc_list = asc_response.json::<api::models::ListApiKeysResponse>();
assert_eq!(asc_list.api_keys[0].id, unused_key.id);
assert_eq!(asc_list.api_keys[1].id, low_spend_key.id);
}

#[tokio::test]
async fn test_api_key_prevents_duplicate_names_in_workspace() {
let server = setup_test_server().await;
Expand Down
32 changes: 27 additions & 5 deletions crates/database/src/repositories/api_key.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use anyhow::{Context, Result};
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use services::common::{extract_api_key_prefix, generate_api_key, hash_api_key, RepositoryError};
use services::workspace::ports::CreateApiKeyRequest;
use services::workspace::ports::{ApiKeyOrderBy, ApiKeyOrderDirection, CreateApiKeyRequest};
use tracing::debug;
use uuid::Uuid;

Expand Down Expand Up @@ -237,7 +237,25 @@ impl ApiKeyRepository {
workspace_id: Uuid,
limit: i64,
offset: i64,
order_by: Option<ApiKeyOrderBy>,
order_direction: Option<ApiKeyOrderDirection>,
) -> Result<Vec<ApiKey>, RepositoryError> {
let order_by = order_by.unwrap_or(ApiKeyOrderBy::CreatedAt);
let order_direction = order_direction.unwrap_or(ApiKeyOrderDirection::Desc);

let order_by_column = match order_by {
ApiKeyOrderBy::CreatedAt => "ak.created_at",
ApiKeyOrderBy::Usage => "usage",
Comment thread
PierreLeGuen marked this conversation as resolved.
};
let order_dir = match order_direction {
ApiKeyOrderDirection::Asc => "ASC",
ApiKeyOrderDirection::Desc => "DESC",
};
let tie_breaker = match order_by {
ApiKeyOrderBy::CreatedAt => ", ak.id ASC",
ApiKeyOrderBy::Usage => ", ak.created_at DESC, ak.id ASC",
};

let rows = retry_db!("list_api_keys_by_workspace_paginated", {
let client = self
.pool
Expand All @@ -248,7 +266,8 @@ impl ApiKeyRepository {

client
.query(
r#"
&format!(
r#"
SELECT
ak.id,
ak.key_hash,
Expand All @@ -267,9 +286,10 @@ impl ApiKeyRepository {
LEFT JOIN organization_usage_log usg ON ak.id = usg.api_key_id
WHERE ak.workspace_id = $1 AND ak.deleted_at IS NULL
GROUP BY ak.id
ORDER BY ak.created_at DESC
ORDER BY {order_by_column} {order_dir}{tie_breaker}
LIMIT $2 OFFSET $3
"#,
"#
),
&[&workspace_id, &limit, &offset],
)
.await
Expand Down Expand Up @@ -620,9 +640,11 @@ impl services::workspace::ports::ApiKeyRepository for ApiKeyRepository {
workspace_id: services::workspace::WorkspaceId,
limit: i64,
offset: i64,
order_by: Option<services::workspace::ApiKeyOrderBy>,
order_direction: Option<services::workspace::ApiKeyOrderDirection>,
) -> Result<Vec<services::workspace::ApiKey>, RepositoryError> {
let api_keys = self
.list_by_workspace_paginated(workspace_id.0, limit, offset)
.list_by_workspace_paginated(workspace_id.0, limit, offset, order_by, order_direction)
.await?;
Ok(api_keys
.into_iter()
Expand Down
4 changes: 3 additions & 1 deletion crates/services/src/workspace/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -253,14 +253,16 @@ impl WorkspaceServiceTrait for WorkspaceServiceImpl {
requester_id: UserId,
limit: i64,
offset: i64,
order_by: Option<ApiKeyOrderBy>,
order_direction: Option<ApiKeyOrderDirection>,
) -> Result<Vec<ApiKey>, WorkspaceError> {
// Check permissions
self.check_workspace_permission(workspace_id.clone(), requester_id)
.await?;

// List API keys with pagination (repository now includes usage data via JOIN)
self.api_key_repository
.list_by_workspace_paginated(workspace_id, limit, offset)
.list_by_workspace_paginated(workspace_id, limit, offset, order_by, order_direction)
.await
.map_err(|e| {
WorkspaceError::InternalError(format!(
Expand Down
18 changes: 18 additions & 0 deletions crates/services/src/workspace/ports.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,20 @@ pub enum WorkspaceOrderDirection {
Desc,
}

#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ApiKeyOrderBy {
CreatedAt,
Usage,
}

#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ApiKeyOrderDirection {
Asc,
Desc,
}

// Repository trait for workspace data access
#[async_trait]
pub trait WorkspaceRepository: Send + Sync {
Expand Down Expand Up @@ -182,6 +196,8 @@ pub trait ApiKeyRepository: Send + Sync {
workspace_id: WorkspaceId,
limit: i64,
offset: i64,
order_by: Option<ApiKeyOrderBy>,
order_direction: Option<ApiKeyOrderDirection>,
Comment thread
PierreLeGuen marked this conversation as resolved.
) -> Result<Vec<ApiKey>, RepositoryError>;

async fn delete(&self, id: ApiKeyId) -> Result<bool, RepositoryError>;
Expand Down Expand Up @@ -281,6 +297,8 @@ pub trait WorkspaceServiceTrait: Send + Sync {
requester_id: UserId,
limit: i64,
offset: i64,
order_by: Option<ApiKeyOrderBy>,
order_direction: Option<ApiKeyOrderDirection>,
) -> Result<Vec<ApiKey>, WorkspaceError>;

/// Get a specific API key by ID with permission checking
Expand Down
Loading