Skip to main content

lenso_api/
openapi.rs

1//! `OpenAPI` document assembly.
2//!
3//! Paths and component schemas are derived directly from the
4//! `#[utoipa::path]`-annotated handlers via `utoipa-axum`'s `OpenApiRouter`, so
5//! there is a single source of truth per endpoint. This module contributes the
6//! document-level metadata (info, tags) and normalizes shared platform error
7//! responses after linked/module routers are merged.
8
9use lenso_bootstrap::CompositionProfile;
10use platform_core::AppContext;
11use platform_http::{ApiOpenApiRouter, OpenApiRouter, base_router};
12use utoipa::OpenApi;
13use utoipa::openapi::RefOr;
14use utoipa::openapi::content::Content;
15use utoipa::openapi::path::Operation;
16use utoipa::openapi::response::Response;
17
18/// Document-level `OpenAPI` metadata shared by every endpoint.
19///
20/// Intentionally declares no `paths` and no per-endpoint `schemas`: those are
21/// collected automatically from the annotated handlers when the router is split
22/// into its parts.
23#[derive(OpenApi)]
24#[openapi(
25    info(
26        title = "Lenso API",
27        version = "1.0.0",
28        description = "Rust-first modular monolith API contract"
29    ),
30    tags(
31        (name = "auth", description = "Auth module development session APIs"),
32        (name = "admin-runtime", description = "Read-only runtime console APIs"),
33        (name = "admin-config", description = "Editable configuration console APIs"),
34        (name = "admin-data", description = "Schema-driven admin data console APIs"),
35        (name = "system-delivery", description = "Production delivery authority APIs")
36    )
37)]
38struct ApiDoc;
39
40/// Assemble the full `OpenAPI` router: base probes, linked module routes, and
41/// admin/runtime routers, seeded with the document-level metadata.
42///
43/// Context-free: route registration and `OpenAPI` metadata never touch the
44/// database, so callers can either serve it (after `with_state` +
45/// `split_for_parts`) or extract the `OpenAPI` document alone.
46pub(crate) fn api_router() -> ApiOpenApiRouter {
47    api_router_for_profile(CompositionProfile::default())
48}
49
50pub(crate) fn api_router_for_profile(profile: CompositionProfile) -> ApiOpenApiRouter {
51    let base = OpenApiRouter::with_openapi(openapi_document_for_profile_with_composition(
52        profile,
53        &lenso_bootstrap::HostComposition::default(),
54    ))
55    .merge(base_router());
56    lenso_bootstrap::merge_linked_http_for_profile(base, profile)
57        .merge(platform_admin::router())
58        .merge(platform_admin_data::router())
59        .merge(platform_module_remote::router())
60        .merge(crate::system_delivery::router())
61}
62
63pub(crate) fn api_router_for_context_with_composition(
64    ctx: &AppContext,
65    composition: &lenso_bootstrap::HostComposition,
66) -> platform_core::AppResult<ApiOpenApiRouter> {
67    let profile = CompositionProfile::from_config(&ctx.config)?;
68    let base = OpenApiRouter::with_openapi(openapi_document_for_profile_with_composition(
69        profile,
70        composition,
71    ))
72    .merge(base_router());
73    Ok(
74        lenso_bootstrap::merge_linked_http_for_context_with_composition(base, ctx, composition)?
75            .merge(platform_admin::router())
76            .merge(platform_admin_data::router())
77            .merge(platform_module_remote::router())
78            .merge(crate::system_delivery::router()),
79    )
80}
81
82fn openapi_document_for_profile_with_composition(
83    profile: CompositionProfile,
84    composition: &lenso_bootstrap::HostComposition,
85) -> utoipa::openapi::OpenApi {
86    let mut document = ApiDoc::openapi();
87    if let Some(tags) = &mut document.tags {
88        let has_auth = profile == CompositionProfile::Demo
89            || composition
90                .linked_modules()
91                .iter()
92                .any(|module| module.module_name == "auth");
93        match profile {
94            CompositionProfile::Core => tags.retain(|tag| has_auth || tag.name != "auth"),
95            CompositionProfile::Demo => {}
96        }
97    }
98    document
99}
100
101/// The committed `OpenAPI` document, derived from the annotated handlers.
102#[must_use]
103pub fn openapi_document() -> utoipa::openapi::OpenApi {
104    let mut document = api_router().to_openapi();
105    normalize_error_response_content_types(&mut document);
106    document
107}
108
109pub(crate) fn normalize_error_response_content_types(document: &mut utoipa::openapi::OpenApi) {
110    for path_item in document.paths.paths.values_mut() {
111        normalize_operation_error_responses(path_item.get.as_mut());
112        normalize_operation_error_responses(path_item.put.as_mut());
113        normalize_operation_error_responses(path_item.post.as_mut());
114        normalize_operation_error_responses(path_item.delete.as_mut());
115        normalize_operation_error_responses(path_item.options.as_mut());
116        normalize_operation_error_responses(path_item.head.as_mut());
117        normalize_operation_error_responses(path_item.patch.as_mut());
118        normalize_operation_error_responses(path_item.trace.as_mut());
119    }
120}
121
122fn normalize_operation_error_responses(operation: Option<&mut Operation>) {
123    let Some(operation) = operation else {
124        return;
125    };
126    for response in operation.responses.responses.values_mut() {
127        if let RefOr::T(response) = response {
128            normalize_response_error_content_type(response);
129        }
130    }
131}
132
133fn normalize_response_error_content_type(response: &mut Response) {
134    let Some(content) = response.content.get("application/json") else {
135        return;
136    };
137    if !is_error_response_content(content) {
138        return;
139    }
140
141    let content = response
142        .content
143        .shift_remove("application/json")
144        .expect("application/json content should exist");
145    response
146        .content
147        .insert("application/problem+json".to_owned(), content);
148}
149
150fn is_error_response_content(content: &Content) -> bool {
151    matches!(
152        &content.schema,
153        Some(RefOr::Ref(reference))
154            if reference.ref_location == "#/components/schemas/ErrorResponse"
155    )
156}