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 :
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);
}