Skip to main content

fastskill_core/http/handlers/
claude_api.rs

1//! Claude Code v1 API endpoints implementation
2//!
3//! This module implements the Claude Code v1 API endpoints for skill management,
4//! matching Anthropic's API specification for skill creation, versioning, and management.
5
6use crate::http::errors::{HttpError, HttpResult};
7use crate::http::handlers::{skill_storage::SkillStorage, AppState};
8use crate::http::models::*;
9use axum::{
10    extract::{Multipart, Path, Query, State},
11    Json,
12};
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15use validator::Validate;
16
17/// Create skill version request (multipart/form-data) - not used directly
18/// We handle multipart parsing manually
19/// Multipart file representation
20#[derive(Debug)]
21pub struct MultipartFile {
22    pub filename: String,
23    pub content: Vec<u8>,
24    pub content_type: Option<String>,
25}
26
27/// Create skill version response
28#[derive(Debug, Serialize)]
29pub struct CreateSkillVersionResponse {
30    pub created_at: String,
31    pub description: String,
32    pub directory: String,
33    pub id: String,
34    pub name: String,
35    pub skill_id: String,
36    pub r#type: String, // Always "skill_version"
37    pub version: String,
38}
39
40/// List skills response
41#[derive(Debug, Serialize)]
42pub struct ListSkillsResponse {
43    pub skills: Vec<SkillInfo>,
44    pub count: usize,
45    pub total: usize,
46}
47
48/// Skill information
49#[derive(Debug, Serialize)]
50pub struct SkillInfo {
51    pub id: String,
52    pub name: String,
53    pub description: String,
54    pub created_at: Option<String>,
55    pub updated_at: Option<String>,
56    pub latest_version: Option<String>,
57    pub source: String, // "anthropic" or "custom"
58}
59
60/// Get skill response
61#[derive(Debug, Serialize)]
62pub struct GetSkillResponse {
63    pub id: String,
64    pub name: String,
65    pub description: String,
66    pub created_at: Option<String>,
67    pub updated_at: Option<String>,
68    pub latest_version: Option<String>,
69    pub source: String,
70}
71
72/// Create skill response
73#[derive(Debug, Serialize)]
74pub struct CreateSkillResponse {
75    pub id: String,
76    pub name: String,
77    pub description: String,
78    pub created_at: String,
79    pub source: String,
80}
81
82/// Query parameters for listing skills
83#[derive(Debug, Deserialize)]
84pub struct ListSkillsQuery {
85    pub source: Option<String>, // Filter by source: "anthropic" or "custom"
86    pub limit: Option<usize>,
87    pub offset: Option<usize>,
88}
89
90/// POST /v1/skills - Create a new skill
91pub async fn create_skill(
92    State(state): State<AppState>,
93    Json(request): Json<CreateSkillRequest>,
94) -> HttpResult<Json<ApiResponse<CreateSkillResponse>>> {
95    // Validate request
96    request.validate().map_err(|e| {
97        HttpError::ValidationError(
98            e.field_errors()
99                .into_iter()
100                .map(|(field, errors)| {
101                    (
102                        field.to_string(),
103                        errors
104                            .iter()
105                            .map(|e| e.message.clone().unwrap_or_default().to_string())
106                            .collect(),
107                    )
108                })
109                .collect(),
110        )
111    })?;
112
113    // Generate skill ID
114    let skill_id = SkillStorage::generate_skill_id();
115
116    // Create skill metadata
117    let skills_dir = state.service.config().skill_storage_path.clone();
118    let _skill_storage = SkillStorage::new(state.service.clone(), skills_dir);
119    let created_at = chrono::Utc::now();
120
121    // For now, create a basic skill entry
122    // In a full implementation, this would create the skill in the registry
123    let response = CreateSkillResponse {
124        id: skill_id,
125        name: request.display_title.clone(),
126        description: request.description.unwrap_or_default(),
127        created_at: created_at.to_rfc3339(),
128        source: "custom".to_string(),
129    };
130
131    Ok(Json(ApiResponse::success(response)))
132}
133
134/// GET /v1/skills - List all skills
135pub async fn list_skills(
136    State(state): State<AppState>,
137    Query(query): Query<ListSkillsQuery>,
138) -> HttpResult<Json<ApiResponse<ListSkillsResponse>>> {
139    let skills_dir = state.service.config().skill_storage_path.clone();
140    let skill_storage = SkillStorage::new(state.service.clone(), skills_dir);
141
142    // Get skills from storage
143    let mut skills = skill_storage.list_skills().await?;
144
145    // Apply source filter
146    if let Some(source_filter) = &query.source {
147        skills.retain(|skill| {
148            // Determine source based on skill ID format or metadata
149            // Anthropic skills have known IDs, custom skills have generated IDs
150            let is_anthropic = matches!(skill.id.as_str(), "pptx" | "xlsx" | "docx" | "pdf");
151            match source_filter.as_str() {
152                "anthropic" => is_anthropic,
153                "custom" => !is_anthropic,
154                _ => true,
155            }
156        });
157    }
158
159    // Apply pagination
160    let offset = query.offset.unwrap_or(0);
161    let limit = query.limit.unwrap_or(50);
162    let total = skills.len();
163    let paginated_skills = skills
164        .into_iter()
165        .skip(offset)
166        .take(limit)
167        .collect::<Vec<_>>();
168    let count = paginated_skills.len();
169
170    // Convert to response format
171    let skill_infos: Vec<SkillInfo> = paginated_skills
172        .into_iter()
173        .map(|skill| {
174            let is_anthropic = matches!(skill.id.as_str(), "pptx" | "xlsx" | "docx" | "pdf");
175            SkillInfo {
176                id: skill.id,
177                name: skill.name,
178                description: skill.description,
179                created_at: Some(skill.created_at.to_rfc3339()),
180                updated_at: Some(skill.updated_at.to_rfc3339()),
181                latest_version: Some(skill.latest_version),
182                source: if is_anthropic {
183                    "anthropic".to_string()
184                } else {
185                    "custom".to_string()
186                },
187            }
188        })
189        .collect();
190
191    let response = ListSkillsResponse {
192        skills: skill_infos,
193        count,
194        total,
195    };
196
197    Ok(Json(ApiResponse::success(response)))
198}
199
200/// GET /v1/skills/{skill_id} - Get skill details
201pub async fn get_skill(
202    State(state): State<AppState>,
203    Path(skill_id): Path<String>,
204) -> HttpResult<Json<ApiResponse<GetSkillResponse>>> {
205    let skills_dir = state.service.config().skill_storage_path.clone();
206    let skill_storage = SkillStorage::new(state.service.clone(), skills_dir);
207
208    let skill = skill_storage
209        .get_skill(&skill_id)
210        .await?
211        .ok_or_else(|| HttpError::NotFound(format!("Skill not found: {}", skill_id)))?;
212
213    let is_anthropic = matches!(skill.id.as_str(), "pptx" | "xlsx" | "docx" | "pdf");
214
215    let response = GetSkillResponse {
216        id: skill.id,
217        name: skill.name,
218        description: skill.description,
219        created_at: Some(skill.created_at.to_rfc3339()),
220        updated_at: Some(skill.updated_at.to_rfc3339()),
221        latest_version: Some(skill.latest_version),
222        source: if is_anthropic {
223            "anthropic".to_string()
224        } else {
225            "custom".to_string()
226        },
227    };
228
229    Ok(Json(ApiResponse::success(response)))
230}
231
232/// DELETE /v1/skills/{skill_id} - Delete a skill
233pub async fn delete_skill(
234    State(state): State<AppState>,
235    Path(skill_id): Path<String>,
236) -> HttpResult<Json<ApiResponse<serde_json::Value>>> {
237    let skills_dir = state.service.config().skill_storage_path.clone();
238    let skill_storage = SkillStorage::new(state.service.clone(), skills_dir);
239
240    skill_storage.delete_skill(&skill_id).await?;
241
242    Ok(Json(ApiResponse::success(serde_json::json!({
243        "message": "Skill deleted successfully"
244    }))))
245}
246
247/// POST /v1/skills/{skill_id}/versions - Create skill version
248pub async fn create_skill_version(
249    State(state): State<AppState>,
250    Path(skill_id): Path<String>,
251    mut multipart: Multipart,
252) -> HttpResult<Json<ApiResponse<CreateSkillVersionResponse>>> {
253    let skills_dir = state.service.config().skill_storage_path.clone();
254    let skill_storage = SkillStorage::new(state.service.clone(), skills_dir);
255
256    // Parse multipart form data
257    let mut files = HashMap::new();
258    while let Some(field) = multipart
259        .next_field()
260        .await
261        .map_err(|e| HttpError::BadRequest(format!("Failed to read multipart field: {}", e)))?
262    {
263        let name = field.name().unwrap_or("unnamed").to_string();
264        if name == "files" || name == "files[]" {
265            let filename = field.file_name().unwrap_or("unnamed").to_string();
266            let content = field.bytes().await.map_err(|e| {
267                HttpError::BadRequest(format!("Failed to read file content: {}", e))
268            })?;
269            files.insert(filename, content.to_vec());
270        }
271    }
272
273    if files.is_empty() {
274        return Err(HttpError::BadRequest("No files provided".to_string()));
275    }
276
277    // Store the skill version
278    let version = skill_storage.store_skill_version(&skill_id, files).await?;
279
280    let response = CreateSkillVersionResponse {
281        created_at: version.created_at.to_rfc3339(),
282        description: version.description,
283        directory: version.directory,
284        id: version.id,
285        name: version.name,
286        skill_id: version.skill_id,
287        r#type: "skill_version".to_string(),
288        version: version.version,
289    };
290
291    Ok(Json(ApiResponse::success(response)))
292}
293
294/// GET /v1/skills/{skill_id}/versions - List skill versions
295pub async fn list_skill_versions(
296    State(_state): State<AppState>,
297    Path(_skill_id): Path<String>,
298) -> HttpResult<Json<ApiResponse<SkillVersionsListResponse>>> {
299    // For now, return empty list - full implementation would track versions
300    let response = SkillVersionsListResponse {
301        versions: vec![],
302        count: 0,
303        total: 0,
304    };
305
306    Ok(Json(ApiResponse::success(response)))
307}
308
309/// GET /v1/skills/{skill_id}/versions/{version} - Get skill version
310pub async fn get_skill_version(
311    State(_state): State<AppState>,
312    Path((_skill_id, _version)): Path<(String, String)>,
313) -> HttpResult<Json<ApiResponse<SkillVersionResponse>>> {
314    // This would need to be implemented to retrieve specific versions
315    Err(HttpError::NotFound(format!(
316        "Skill version not found: {}@{}",
317        _skill_id, _version
318    )))
319}
320
321/// DELETE /v1/skills/{skill_id}/versions/{version} - Delete skill version
322pub async fn delete_skill_version(
323    State(_state): State<AppState>,
324    Path((_skill_id, _version)): Path<(String, String)>,
325) -> HttpResult<Json<ApiResponse<serde_json::Value>>> {
326    // This would need to be implemented to delete specific versions
327    Err(HttpError::NotFound(format!(
328        "Skill version not found: {}@{}",
329        _skill_id, _version
330    )))
331}
332
333// Additional response types
334#[derive(Debug, Serialize)]
335pub struct SkillVersionsListResponse {
336    pub versions: Vec<SkillVersionInfo>,
337    pub count: usize,
338    pub total: usize,
339}
340
341#[derive(Debug, Serialize)]
342pub struct SkillVersionInfo {
343    pub id: String,
344    pub version: String,
345    pub created_at: String,
346    pub name: String,
347    pub description: String,
348}
349
350#[derive(Debug, Serialize)]
351pub struct SkillVersionResponse {
352    pub id: String,
353    pub skill_id: String,
354    pub version: String,
355    pub created_at: String,
356    pub name: String,
357    pub description: String,
358    pub directory: String,
359    pub r#type: String,
360}
361
362// Request models
363#[derive(Debug, Deserialize, validator::Validate)]
364pub struct CreateSkillRequest {
365    #[validate(length(min = 1, max = 64))]
366    pub display_title: String,
367    #[validate(length(max = 1024))]
368    pub description: Option<String>,
369}