Implémenter un endpoint REST /api/v1/...
Ce guide couvre l'ajout d'un nouvel endpoint JSON à l'API REST v1 de gitrust : modèle de requête/réponse, authentification PAT/JWT, codes d'erreur standardisés, et tests.
Pré-requis
- Lecture de
ajouter-service-metier.md. - Référence complète de l'API :
reference/api-rest-v1.md.
Vue d'ensemble
Les handlers API et les handlers SSR partagent les mêmes services. La seule différence est le format de réponse :
Handler SSR → service → réponse HTML (template Askama) Handler API → service → réponse JSON (serde_json)
Ne dupliquez jamais la logique métier entre un handler SSR et un handler API.
Structure des handlers API
Les handlers API se trouvent dans crates/gitrust-web/src/handlers/api/. Chaque domaine a son fichier :
handlers/api/ ├── repos.rs — dépôts, branches, commits ├── issues.rs — issues et commentaires ├── pulls.rs — pull requests ├── ci.rs — pipelines CI ├── user.rs — profil utilisateur courant └── docs.rs — Swagger UI
Étape 1 : Définir les types de requête et réponse
Créez ou complétez les DTOs dans crates/gitrust-core/src/dto/ :
// crates/gitrust-core/src/dto/mon_api_dto.rs use serde::{Deserialize, Serialize}; use uuid::Uuid; /// Corps de la requête POST /api/v1/repos/{owner}/{repo}/mon-resource #[derive(Debug, Deserialize)] pub struct CreateMonResourceRequest { pub name: String, pub description: Option<String>, } /// Réponse JSON pour un élément unique #[derive(Debug, Serialize)] pub struct MonResourceResponse { pub id: Uuid, pub name: String, pub description: Option<String>, pub created_at: chrono::DateTime<chrono::Utc>, } /// Réponse JSON pour une liste paginée #[derive(Debug, Serialize)] pub struct PaginatedResponse<T> { pub items: Vec<T>, pub total: u64, pub page: u64, pub per_page: u64, }
Étape 2 : Déclarer la route dans routes.rs
Dans la fonction api_v1_routes() de crates/gitrust-web/src/routes.rs :
.route( "/api/v1/repos/{owner}/{repo}/mon-resource", get(handlers::api::mon_resource::list).post(handlers::api::mon_resource::create), ) .route( "/api/v1/repos/{owner}/{repo}/mon-resource/{id}", get(handlers::api::mon_resource::detail) .patch(handlers::api::mon_resource::update) .delete(handlers::api::mon_resource::delete_resource), )
Étape 3 : Implémenter le handler
// crates/gitrust-web/src/handlers/api/mon_resource.rs use axum::{ extract::{Path, Query, State}, http::StatusCode, response::IntoResponse, Json, }; use sea_orm::DatabaseConnection; use serde::Deserialize; use crate::auth::AuthUser; use gitrust_core::{ dto::mon_api_dto::{CreateMonResourceRequest, MonResourceResponse, PaginatedResponse}, services::mon_service::MonService, }; #[derive(Deserialize)] pub struct PaginationParams { #[serde(default = "default_page")] pub page: u64, #[serde(default = "default_per_page")] pub per_page: u64, } fn default_page() -> u64 { 1 } fn default_per_page() -> u64 { 30 } /// GET /api/v1/repos/{owner}/{repo}/mon-resource pub async fn list( State(db): State<DatabaseConnection>, Path((owner, repo)): Path<(String, String)>, Query(pagination): Query<PaginationParams>, user: AuthUser, ) -> impl IntoResponse { let per_page = pagination.per_page.min(100); // cap à 100 match MonService::list_paginated( &db, &owner, &repo, user.user_id, pagination.page, per_page, ) .await { Ok((items, total)) => { let response = PaginatedResponse { items: items.into_iter().map(MonResourceResponse::from).collect(), total, page: pagination.page, per_page, }; let mut headers = axum::http::HeaderMap::new(); headers.insert("X-Total-Count", total.to_string().parse().unwrap()); headers.insert("X-Page", pagination.page.to_string().parse().unwrap()); headers.insert("X-Per-Page", per_page.to_string().parse().unwrap()); (StatusCode::OK, headers, Json(response)).into_response() } Err(e) => api_error(e).into_response(), } } /// POST /api/v1/repos/{owner}/{repo}/mon-resource pub async fn create( State(db): State<DatabaseConnection>, Path((owner, repo)): Path<(String, String)>, user: AuthUser, Json(body): Json<CreateMonResourceRequest>, ) -> impl IntoResponse { match MonService::create_for_repo(&db, &owner, &repo, user.user_id, body.into()).await { Ok(model) => (StatusCode::CREATED, Json(MonResourceResponse::from(model))).into_response(), Err(e) => api_error(e).into_response(), } }
Authentification : PAT et JWT Bearer
L'extracteur AuthUser supporte deux modes d'authentification :
- Cookie JWT (
jwt_token) — utilisé par le navigateur web. - Header
Authorization: Bearer <token>— utilisé par les clients API avec un PAT ou un JWT.
Les Personal Access Tokens sont vérifiés via PatService::validate_token() dans le middleware. Aucune modification n'est nécessaire côté handler — AuthUser gère les deux méthodes transparentement.
Codes de retour en cas d'échec d'auth :
401 Unauthorized— token absent, expiré, ou invalide. Réponse JSON, jamais de redirect HTML.403 Forbidden— token valide mais permissions insuffisantes.
Format des erreurs JSON standardisé
Toutes les erreurs API retournent le même format :
{ "error": "not_found", "message": "Le dépôt 'alice/myrepo' est introuvable", "status": 404 }
La fonction utilitaire api_error à placer dans handlers/api/mod.rs :
use axum::{http::StatusCode, response::IntoResponse, Json}; use serde_json::json; use gitrust_core::error::GitrustError; pub fn api_error(err: GitrustError) -> impl IntoResponse { let (status, code) = match &err { GitrustError::NotFound(_) => (StatusCode::NOT_FOUND, "not_found"), GitrustError::Validation(_) => (StatusCode::BAD_REQUEST, "validation_error"), GitrustError::Forbidden => (StatusCode::FORBIDDEN, "forbidden"), GitrustError::Conflict(_) => (StatusCode::CONFLICT, "conflict"), _ => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error"), }; ( status, Json(json!({ "error": code, "message": err.to_string(), "status": status.as_u16() })), ) }
Pagination
Tous les endpoints de liste supportent ?page=N&per_page=N (défaut : page=1, per_page=30, max per_page=100).
Headers de réponse :
X-Total-Count: 142 X-Page: 2 X-Per-Page: 30 Link: <https://demo.gitrust.eu/api/v1/repos/alice/myrepo/issues?page=1&per_page=30>; rel="prev", <https://demo.gitrust.eu/api/v1/repos/alice/myrepo/issues?page=3&per_page=30>; rel="next"
Rate limiting
Les endpoints API sont couverts par le middleware de rate limiting de rustwarden-core :
| Catégorie | Limite | Fenêtre |
|---|---|---|
| Endpoints lecture | 1000 req | 1 heure |
| Endpoints écriture | 200 req | 1 heure |
| Trigger CI manuel | 10 req | 1 heure |
En cas de dépassement : 429 Too Many Requests avec header Retry-After: <secondes>.
Documenter l'endpoint (OpenAPI)
Ajoutez les annotations utoipa sur le handler :
#[utoipa::path( get, path = "/api/v1/repos/{owner}/{repo}/mon-resource", params( ("owner" = String, Path, description = "Propriétaire du dépôt"), ("repo" = String, Path, description = "Slug du dépôt"), ("page" = Option<u64>, Query, description = "Numéro de page (défaut: 1)"), ("per_page" = Option<u64>, Query, description = "Éléments par page (défaut: 30, max: 100)"), ), responses( (status = 200, description = "Liste paginée", body = PaginatedResponse<MonResourceResponse>), (status = 401, description = "Non authentifié"), (status = 403, description = "Accès refusé"), (status = 404, description = "Dépôt introuvable"), ), security(("bearer_token" = []), ("cookie_auth" = [])) )] pub async fn list(...) { ... }
Tester l'endpoint
Test curl rapide :
# Avec un PAT curl -H "Authorization: Bearer <votre-pat>" \ https://demo.gitrust.eu/api/v1/repos/alice/myrepo/mon-resource # Vérifier le format d'erreur sur auth invalide curl -H "Authorization: Bearer token-invalide" \ https://demo.gitrust.eu/api/v1/repos/alice/myrepo/mon-resource # → {"error":"unauthorized","message":"Token invalide","status":401}
Test d'intégration Rust :
#[tokio::test] async fn list_returns_401_without_auth() { let app = setup_test_app().await; let response = app .oneshot( Request::builder() .uri("/api/v1/repos/alice/myrepo/mon-resource") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); let body: serde_json::Value = parse_json_body(response).await; assert_eq!(body["error"], "unauthorized"); } #[tokio::test] async fn create_returns_201_with_valid_pat() { let app = setup_test_app().await; let pat = create_test_pat(&app).await; let response = app .oneshot( Request::builder() .method("POST") .uri("/api/v1/repos/alice/myrepo/mon-resource") .header("Authorization", format!("Bearer {pat}")) .header("Content-Type", "application/json") .body(Body::from(r#"{"name":"test"}"#)) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::CREATED); }
GitRust