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

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 :

  1. Cookie JWT (jwt_token) — utilisé par le navigateur web.
  2. 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);
}

Voir aussi