1use serde::{Deserialize, Serialize};
2
3use crate::config::{ConfigPathOptions, resolve_config};
4use crate::error::{CoreError, Result};
5use crate::http::blocking_client;
6
7const ME_API_KEYS_PATH: &str = "/v1/me/api-keys";
8
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "camelCase")]
11pub struct ApiKeyMetadata {
12 pub id: String,
13 pub name: String,
14 pub status: String,
15 pub created_at: String,
16 #[serde(skip_serializing_if = "Option::is_none")]
17 pub last_used_at: Option<String>,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "camelCase")]
22pub struct ApiKeyWithToken {
23 pub id: String,
24 pub name: String,
25 pub status: String,
26 pub created_at: String,
27 #[serde(skip_serializing_if = "Option::is_none")]
28 pub last_used_at: Option<String>,
29 pub token: String,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "camelCase")]
34pub struct ApiKeyCreateResult {
35 pub api_key: ApiKeyWithToken,
36 pub registry_url: String,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "camelCase")]
41pub struct ApiKeyListResult {
42 pub api_keys: Vec<ApiKeyMetadata>,
43 pub registry_url: String,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "camelCase")]
48pub struct ApiKeyRevokeResult {
49 pub api_key_id: String,
50 pub registry_url: String,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct ApiKeyCreateInput {
55 pub name: Option<String>,
56 pub registry_url: Option<String>,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct ApiKeyListInput {
61 pub registry_url: Option<String>,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct ApiKeyRevokeInput {
66 pub id: String,
67 pub registry_url: Option<String>,
68}
69
70#[derive(Debug, Deserialize)]
71#[serde(rename_all = "camelCase")]
72struct ApiKeyCreateResponse {
73 api_key: ApiKeyWithTokenPayload,
74}
75
76#[derive(Debug, Deserialize)]
77#[serde(rename_all = "camelCase")]
78struct ApiKeyListResponse {
79 api_keys: Vec<ApiKeyMetadataPayload>,
80}
81
82#[derive(Debug, Deserialize)]
83#[serde(rename_all = "camelCase")]
84struct ApiKeyWithTokenPayload {
85 id: String,
86 name: String,
87 status: String,
88 created_at: String,
89 last_used_at: Option<String>,
90 token: String,
91}
92
93#[derive(Debug, Deserialize)]
94#[serde(rename_all = "camelCase")]
95struct ApiKeyMetadataPayload {
96 id: String,
97 name: String,
98 status: String,
99 created_at: String,
100 last_used_at: Option<String>,
101}
102
103#[derive(Debug, Deserialize)]
104#[serde(rename_all = "camelCase")]
105struct ErrorEnvelope {
106 error: Option<RegistryError>,
107}
108
109#[derive(Debug, Deserialize)]
110#[serde(rename_all = "camelCase")]
111struct RegistryError {
112 message: Option<String>,
113}
114
115#[derive(Debug, Clone)]
116struct ApiKeyRuntime {
117 registry_url: String,
118 api_key: String,
119}
120
121fn parse_non_empty(value: &str, field: &str) -> Result<String> {
122 let trimmed = value.trim();
123 if trimmed.is_empty() {
124 return Err(CoreError::InvalidInput(format!(
125 "{field} in API key response is invalid"
126 )));
127 }
128 Ok(trimmed.to_string())
129}
130
131fn parse_api_key_status(status: &str) -> Result<String> {
132 let normalized = status.trim();
133 if normalized.is_empty() {
134 return Err(CoreError::InvalidInput(
135 "status in API key response is invalid".to_string(),
136 ));
137 }
138 Ok(normalized.to_string())
139}
140
141fn normalize_registry_url(value: &str) -> Result<String> {
142 url::Url::parse(value.trim())
143 .map(|url| url.to_string())
144 .map_err(|_| CoreError::InvalidUrl {
145 context: "registryUrl",
146 value: value.to_string(),
147 })
148}
149
150fn resolve_runtime(
151 options: &ConfigPathOptions,
152 override_registry_url: Option<String>,
153) -> Result<ApiKeyRuntime> {
154 let config = resolve_config(options)?;
155 let registry_url = normalize_registry_url(
156 override_registry_url
157 .as_deref()
158 .unwrap_or(config.registry_url.as_str()),
159 )?;
160 let api_key = config
161 .api_key
162 .map(|value| value.trim().to_string())
163 .filter(|value| !value.is_empty())
164 .ok_or_else(|| {
165 CoreError::InvalidInput(
166 "API key is not configured. Use `config set apiKey <token>` first.".to_string(),
167 )
168 })?;
169
170 Ok(ApiKeyRuntime {
171 registry_url,
172 api_key,
173 })
174}
175
176fn to_api_key_request_url(registry_url: &str, api_key_id: Option<&str>) -> Result<String> {
177 let base = if registry_url.ends_with('/') {
178 registry_url.to_string()
179 } else {
180 format!("{registry_url}/")
181 };
182 let path = match api_key_id {
183 Some(id) => format!("{}/{}", ME_API_KEYS_PATH.trim_start_matches('/'), id),
184 None => ME_API_KEYS_PATH.trim_start_matches('/').to_string(),
185 };
186 let joined = url::Url::parse(&base)
187 .map_err(|_| CoreError::InvalidUrl {
188 context: "registryUrl",
189 value: registry_url.to_string(),
190 })?
191 .join(&path)
192 .map_err(|_| CoreError::InvalidUrl {
193 context: "registryUrl",
194 value: registry_url.to_string(),
195 })?;
196 Ok(joined.to_string())
197}
198
199fn parse_error_message(response_body: &str) -> String {
200 match serde_json::from_str::<ErrorEnvelope>(response_body) {
201 Ok(envelope) => envelope
202 .error
203 .and_then(|error| error.message)
204 .unwrap_or_else(|| response_body.to_string()),
205 Err(_) => response_body.to_string(),
206 }
207}
208
209fn parse_api_key_with_token(payload: ApiKeyWithTokenPayload) -> Result<ApiKeyWithToken> {
210 Ok(ApiKeyWithToken {
211 id: parse_non_empty(&payload.id, "id")?,
212 name: parse_non_empty(&payload.name, "name")?,
213 status: parse_api_key_status(&payload.status)?,
214 created_at: parse_non_empty(&payload.created_at, "createdAt")?,
215 last_used_at: payload
216 .last_used_at
217 .map(|value| value.trim().to_string())
218 .filter(|value| !value.is_empty()),
219 token: parse_non_empty(&payload.token, "token")?,
220 })
221}
222
223fn parse_api_key_metadata(payload: ApiKeyMetadataPayload) -> Result<ApiKeyMetadata> {
224 Ok(ApiKeyMetadata {
225 id: parse_non_empty(&payload.id, "id")?,
226 name: parse_non_empty(&payload.name, "name")?,
227 status: parse_api_key_status(&payload.status)?,
228 created_at: parse_non_empty(&payload.created_at, "createdAt")?,
229 last_used_at: payload
230 .last_used_at
231 .map(|value| value.trim().to_string())
232 .filter(|value| !value.is_empty()),
233 })
234}
235
236fn parse_api_key_id(value: &str) -> Result<String> {
237 let trimmed = value.trim();
238 if trimmed.is_empty() {
239 return Err(CoreError::InvalidInput(
240 "API key id is required".to_string(),
241 ));
242 }
243 ulid::Ulid::from_string(trimmed)
244 .map(|_| trimmed.to_string())
245 .map_err(|_| CoreError::InvalidInput("API key id must be a valid ULID".to_string()))
246}
247
248pub fn create_api_key(
250 options: &ConfigPathOptions,
251 input: ApiKeyCreateInput,
252) -> Result<ApiKeyCreateResult> {
253 let runtime = resolve_runtime(options, input.registry_url)?;
254 let response = blocking_client()?
255 .post(to_api_key_request_url(&runtime.registry_url, None)?)
256 .header("authorization", format!("Bearer {}", runtime.api_key))
257 .header("content-type", "application/json")
258 .json(&serde_json::json!({
259 "name": input
260 .name
261 .as_deref()
262 .map(str::trim)
263 .filter(|value| !value.is_empty()),
264 }))
265 .send()
266 .map_err(|error| CoreError::Http(error.to_string()))?;
267
268 if !response.status().is_success() {
269 let status = response.status().as_u16();
270 let response_body = response.text().unwrap_or_default();
271 return Err(CoreError::HttpStatus {
272 status,
273 message: parse_error_message(&response_body),
274 });
275 }
276
277 let payload = response
278 .json::<ApiKeyCreateResponse>()
279 .map_err(|error| CoreError::Http(error.to_string()))?;
280 Ok(ApiKeyCreateResult {
281 api_key: parse_api_key_with_token(payload.api_key)?,
282 registry_url: runtime.registry_url,
283 })
284}
285
286pub fn list_api_keys(
288 options: &ConfigPathOptions,
289 input: ApiKeyListInput,
290) -> Result<ApiKeyListResult> {
291 let runtime = resolve_runtime(options, input.registry_url)?;
292 let response = blocking_client()?
293 .get(to_api_key_request_url(&runtime.registry_url, None)?)
294 .header("authorization", format!("Bearer {}", runtime.api_key))
295 .send()
296 .map_err(|error| CoreError::Http(error.to_string()))?;
297
298 if !response.status().is_success() {
299 let status = response.status().as_u16();
300 let response_body = response.text().unwrap_or_default();
301 return Err(CoreError::HttpStatus {
302 status,
303 message: parse_error_message(&response_body),
304 });
305 }
306
307 let payload = response
308 .json::<ApiKeyListResponse>()
309 .map_err(|error| CoreError::Http(error.to_string()))?;
310 let api_keys = payload
311 .api_keys
312 .into_iter()
313 .map(parse_api_key_metadata)
314 .collect::<Result<Vec<_>>>()?;
315 Ok(ApiKeyListResult {
316 api_keys,
317 registry_url: runtime.registry_url,
318 })
319}
320
321pub fn revoke_api_key(
323 options: &ConfigPathOptions,
324 input: ApiKeyRevokeInput,
325) -> Result<ApiKeyRevokeResult> {
326 let runtime = resolve_runtime(options, input.registry_url)?;
327 let api_key_id = parse_api_key_id(&input.id)?;
328 let response = blocking_client()?
329 .delete(to_api_key_request_url(
330 &runtime.registry_url,
331 Some(&api_key_id),
332 )?)
333 .header("authorization", format!("Bearer {}", runtime.api_key))
334 .send()
335 .map_err(|error| CoreError::Http(error.to_string()))?;
336
337 if !response.status().is_success() {
338 let status = response.status().as_u16();
339 let response_body = response.text().unwrap_or_default();
340 return Err(CoreError::HttpStatus {
341 status,
342 message: parse_error_message(&response_body),
343 });
344 }
345
346 Ok(ApiKeyRevokeResult {
347 api_key_id,
348 registry_url: runtime.registry_url,
349 })
350}
351
352#[cfg(test)]
353mod tests {
354 use std::path::Path;
355
356 use tempfile::TempDir;
357 use wiremock::matchers::{header, method, path};
358 use wiremock::{Mock, MockServer, ResponseTemplate};
359
360 use crate::config::{CliConfig, ConfigPathOptions, write_config};
361
362 use super::{
363 ApiKeyCreateInput, ApiKeyListInput, ApiKeyMetadataPayload, ApiKeyRevokeInput,
364 create_api_key, list_api_keys, parse_api_key_metadata, revoke_api_key,
365 };
366
367 fn options(home: &Path) -> ConfigPathOptions {
368 ConfigPathOptions {
369 home_dir: Some(home.to_path_buf()),
370 registry_url_hint: None,
371 }
372 }
373
374 fn seed_config(home: &Path, registry_url: &str) {
375 let options = options(home);
376 let config = CliConfig {
377 registry_url: registry_url.to_string(),
378 proxy_url: None,
379 api_key: Some("pat_local".to_string()),
380 human_name: Some("alice".to_string()),
381 };
382 let _ = write_config(&config, &options).expect("write config");
383 }
384
385 #[tokio::test]
386 async fn create_list_and_revoke_api_key_round_trip() {
387 let server = MockServer::start().await;
388 Mock::given(method("POST"))
389 .and(path("/v1/me/api-keys"))
390 .and(header("authorization", "Bearer pat_local"))
391 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
392 "apiKey": {
393 "id": "01HF7YAT00W6W7CM7N3W5FDXT4",
394 "name": "primary",
395 "status": "active",
396 "createdAt": "2030-01-01T00:00:00.000Z",
397 "lastUsedAt": null,
398 "token": "pat_123"
399 }
400 })))
401 .mount(&server)
402 .await;
403 Mock::given(method("GET"))
404 .and(path("/v1/me/api-keys"))
405 .and(header("authorization", "Bearer pat_local"))
406 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
407 "apiKeys": [{
408 "id": "01HF7YAT00W6W7CM7N3W5FDXT4",
409 "name": "primary",
410 "status": "active",
411 "createdAt": "2030-01-01T00:00:00.000Z",
412 "lastUsedAt": "2030-01-02T00:00:00.000Z"
413 }]
414 })))
415 .mount(&server)
416 .await;
417 Mock::given(method("DELETE"))
418 .and(path("/v1/me/api-keys/01HF7YAT00W6W7CM7N3W5FDXT4"))
419 .and(header("authorization", "Bearer pat_local"))
420 .respond_with(ResponseTemplate::new(204))
421 .mount(&server)
422 .await;
423
424 let temp = TempDir::new().expect("temp dir");
425 seed_config(temp.path(), &server.uri());
426 let options = options(temp.path());
427
428 let create_options = options.clone();
429 let created = tokio::task::spawn_blocking(move || {
430 create_api_key(
431 &create_options,
432 ApiKeyCreateInput {
433 name: Some("primary".to_string()),
434 registry_url: None,
435 },
436 )
437 })
438 .await
439 .expect("join")
440 .expect("create");
441 assert_eq!(created.api_key.token, "pat_123");
442
443 let list_options = options.clone();
444 let listed = tokio::task::spawn_blocking(move || {
445 list_api_keys(&list_options, ApiKeyListInput { registry_url: None })
446 })
447 .await
448 .expect("join")
449 .expect("list");
450 assert_eq!(listed.api_keys.len(), 1);
451 assert_eq!(
452 listed.api_keys[0].last_used_at.as_deref(),
453 Some("2030-01-02T00:00:00.000Z")
454 );
455
456 let revoke_options = options.clone();
457 let revoked = tokio::task::spawn_blocking(move || {
458 revoke_api_key(
459 &revoke_options,
460 ApiKeyRevokeInput {
461 id: "01HF7YAT00W6W7CM7N3W5FDXT4".to_string(),
462 registry_url: None,
463 },
464 )
465 })
466 .await
467 .expect("join")
468 .expect("revoke");
469 assert_eq!(revoked.api_key_id, "01HF7YAT00W6W7CM7N3W5FDXT4");
470 }
471
472 #[test]
473 fn revoke_rejects_invalid_ulid() {
474 let temp = TempDir::new().expect("temp dir");
475 seed_config(temp.path(), "https://registry.example");
476 let options = options(temp.path());
477 let error = revoke_api_key(
478 &options,
479 ApiKeyRevokeInput {
480 id: "not-ulid".to_string(),
481 registry_url: None,
482 },
483 )
484 .expect_err("invalid id");
485 assert!(error.to_string().contains("valid ULID"));
486 }
487
488 #[test]
489 fn accepts_unknown_status_values_from_registry_payload() {
490 let parsed = parse_api_key_metadata(ApiKeyMetadataPayload {
491 id: "01HF7YAT00W6W7CM7N3W5FDXT4".to_string(),
492 name: "primary".to_string(),
493 status: "pending-review".to_string(),
494 created_at: "2030-01-01T00:00:00.000Z".to_string(),
495 last_used_at: None,
496 })
497 .expect("metadata parse");
498 assert_eq!(parsed.status, "pending-review");
499 }
500}