Line data Source code
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 :
9 : use crate::build_info::{BuildInfo, BuildInfoProvider};
10 : use axum::{extract::Extension, http::StatusCode, response::IntoResponse, Json};
11 : use serde::Serialize;
12 : use utoipa::{OpenApi, ToSchema};
13 :
14 : /// RFC 7807 Problem Details error response.
15 : #[derive(Debug, Serialize, ToSchema)]
16 : #[serde(rename_all = "camelCase")]
17 : pub 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)]
37 : pub 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 :
45 : impl ProblemDetails {
46 : /// Create an internal server error response.
47 : #[must_use]
48 1 : pub fn internal_error(detail: &str) -> Self {
49 1 : Self {
50 1 : problem_type: "https://tinycongress.com/errors/internal".to_string(),
51 1 : title: "Internal Server Error".to_string(),
52 1 : status: 500,
53 1 : detail: detail.to_string(),
54 1 : instance: None,
55 1 : extensions: Some(ProblemExtensions {
56 1 : code: "INTERNAL_ERROR".to_string(),
57 1 : field: None,
58 1 : }),
59 1 : }
60 1 : }
61 : }
62 :
63 : impl IntoResponse for ProblemDetails {
64 0 : fn into_response(self) -> axum::response::Response {
65 0 : let status = StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
66 0 : (status, Json(self)).into_response()
67 0 : }
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 : )]
85 : pub 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
104 3 : pub async fn get_build_info(
105 3 : Extension(provider): Extension<BuildInfoProvider>,
106 3 : ) -> Result<Json<BuildInfo>, ProblemDetails> {
107 3 : Ok(Json(provider.build_info()))
108 3 : }
109 :
110 : #[cfg(test)]
111 : mod tests {
112 : use super::*;
113 :
114 : #[test]
115 1 : fn problem_details_serializes_correctly() {
116 1 : let problem = ProblemDetails::internal_error("Something went wrong");
117 1 : let json = serde_json::to_string(&problem).expect("serialize");
118 1 : assert!(json.contains("\"type\":"));
119 1 : assert!(json.contains("INTERNAL_ERROR"));
120 1 : }
121 : }
|