tinycongress_api/
rest.rs

1//! REST API handlers and `OpenAPI` documentation.
2//!
3//! This module provides REST endpoints alongside GraphQL, sharing the same
4//! domain types with `ToSchema` derives for `OpenAPI` spec generation.
5
6// The OpenApi derive macro generates code that triggers this lint
7#![allow(clippy::needless_for_each)]
8
9use crate::build_info::{BuildInfo, BuildInfoProvider};
10use axum::{extract::Extension, http::StatusCode, response::IntoResponse, Json};
11use serde::Serialize;
12use utoipa::{OpenApi, ToSchema};
13
14/// RFC 7807 Problem Details error response.
15#[derive(Debug, Serialize, ToSchema)]
16#[serde(rename_all = "camelCase")]
17pub struct ProblemDetails {
18    /// URI reference identifying the problem type
19    #[serde(rename = "type")]
20    pub problem_type: String,
21    /// Short human-readable summary
22    pub title: String,
23    /// HTTP status code
24    pub status: u16,
25    /// Human-readable explanation specific to this occurrence
26    pub detail: String,
27    /// URI reference identifying the specific occurrence
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub instance: Option<String>,
30    /// Additional error details
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub extensions: Option<ProblemExtensions>,
33}
34
35/// Extended error information mapping to GraphQL error codes.
36#[derive(Debug, Serialize, ToSchema)]
37pub struct ProblemExtensions {
38    /// Error code matching GraphQL error codes
39    pub code: String,
40    /// Field that caused the error (for validation errors)
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub field: Option<String>,
43}
44
45impl ProblemDetails {
46    /// Create an internal server error response.
47    #[must_use]
48    pub fn internal_error(detail: &str) -> Self {
49        Self {
50            problem_type: "https://tinycongress.com/errors/internal".to_string(),
51            title: "Internal Server Error".to_string(),
52            status: 500,
53            detail: detail.to_string(),
54            instance: None,
55            extensions: Some(ProblemExtensions {
56                code: "INTERNAL_ERROR".to_string(),
57                field: None,
58            }),
59        }
60    }
61}
62
63impl IntoResponse for ProblemDetails {
64    fn into_response(self) -> axum::response::Response {
65        let status = StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
66        (status, Json(self)).into_response()
67    }
68}
69
70/// `OpenAPI` documentation for the REST API.
71#[derive(OpenApi)]
72#[openapi(
73    info(
74        title = "TinyCongress API",
75        version = "1.0.0",
76        description = "REST API for TinyCongress",
77        license(name = "MIT")
78    ),
79    servers(
80        (url = "/api/v1", description = "REST API v1")
81    ),
82    paths(get_build_info),
83    components(schemas(BuildInfo, ProblemDetails, ProblemExtensions))
84)]
85pub struct ApiDoc;
86
87/// Get build information
88///
89/// Returns metadata about the running service including version, git SHA, and build time.
90///
91/// # Errors
92///
93/// Returns `ProblemDetails` on internal server errors.
94#[utoipa::path(
95    get,
96    path = "/build-info",
97    tag = "System",
98    responses(
99        (status = 200, description = "Build information retrieved successfully", body = BuildInfo),
100        (status = 500, description = "Internal server error", body = ProblemDetails)
101    )
102)]
103#[allow(clippy::unused_async)] // Required for Axum handler signature
104pub async fn get_build_info(
105    Extension(provider): Extension<BuildInfoProvider>,
106) -> Result<Json<BuildInfo>, ProblemDetails> {
107    Ok(Json(provider.build_info()))
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    #[test]
115    fn problem_details_serializes_correctly() {
116        let problem = ProblemDetails::internal_error("Something went wrong");
117        let json = serde_json::to_string(&problem).expect("serialize");
118        assert!(json.contains("\"type\":"));
119        assert!(json.contains("INTERNAL_ERROR"));
120    }
121}