1use 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#[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 )
36)]
37struct ApiDoc;
38
39pub(crate) fn api_router() -> ApiOpenApiRouter {
46 api_router_for_profile(CompositionProfile::default())
47}
48
49pub(crate) fn api_router_for_profile(profile: CompositionProfile) -> ApiOpenApiRouter {
50 let base = OpenApiRouter::with_openapi(openapi_document_for_profile_with_composition(
51 profile,
52 &lenso_bootstrap::HostComposition::default(),
53 ))
54 .merge(base_router());
55 lenso_bootstrap::merge_linked_http_for_profile(base, profile)
56 .merge(platform_admin::router())
57 .merge(platform_admin_data::router())
58 .merge(platform_module_remote::router())
59}
60
61pub(crate) fn api_router_for_context_with_composition(
62 ctx: &AppContext,
63 composition: &lenso_bootstrap::HostComposition,
64) -> platform_core::AppResult<ApiOpenApiRouter> {
65 let profile = CompositionProfile::from_config(&ctx.config)?;
66 let base = OpenApiRouter::with_openapi(openapi_document_for_profile_with_composition(
67 profile,
68 composition,
69 ))
70 .merge(base_router());
71 Ok(
72 lenso_bootstrap::merge_linked_http_for_context_with_composition(base, ctx, composition)?
73 .merge(platform_admin::router())
74 .merge(platform_admin_data::router())
75 .merge(platform_module_remote::router()),
76 )
77}
78
79fn openapi_document_for_profile_with_composition(
80 profile: CompositionProfile,
81 composition: &lenso_bootstrap::HostComposition,
82) -> utoipa::openapi::OpenApi {
83 let mut document = ApiDoc::openapi();
84 if let Some(tags) = &mut document.tags {
85 let has_auth = profile == CompositionProfile::Demo
86 || composition
87 .linked_modules()
88 .iter()
89 .any(|module| module.module_name == "auth");
90 match profile {
91 CompositionProfile::Core => tags.retain(|tag| has_auth || tag.name != "auth"),
92 CompositionProfile::Demo => {}
93 }
94 }
95 document
96}
97
98#[must_use]
100pub fn openapi_document() -> utoipa::openapi::OpenApi {
101 let mut document = api_router().to_openapi();
102 normalize_error_response_content_types(&mut document);
103 document
104}
105
106pub(crate) fn normalize_error_response_content_types(document: &mut utoipa::openapi::OpenApi) {
107 for path_item in document.paths.paths.values_mut() {
108 normalize_operation_error_responses(path_item.get.as_mut());
109 normalize_operation_error_responses(path_item.put.as_mut());
110 normalize_operation_error_responses(path_item.post.as_mut());
111 normalize_operation_error_responses(path_item.delete.as_mut());
112 normalize_operation_error_responses(path_item.options.as_mut());
113 normalize_operation_error_responses(path_item.head.as_mut());
114 normalize_operation_error_responses(path_item.patch.as_mut());
115 normalize_operation_error_responses(path_item.trace.as_mut());
116 }
117}
118
119fn normalize_operation_error_responses(operation: Option<&mut Operation>) {
120 let Some(operation) = operation else {
121 return;
122 };
123 for response in operation.responses.responses.values_mut() {
124 if let RefOr::T(response) = response {
125 normalize_response_error_content_type(response);
126 }
127 }
128}
129
130fn normalize_response_error_content_type(response: &mut Response) {
131 let Some(content) = response.content.get("application/json") else {
132 return;
133 };
134 if !is_error_response_content(content) {
135 return;
136 }
137
138 let content = response
139 .content
140 .shift_remove("application/json")
141 .expect("application/json content should exist");
142 response
143 .content
144 .insert("application/problem+json".to_owned(), content);
145}
146
147fn is_error_response_content(content: &Content) -> bool {
148 matches!(
149 &content.schema,
150 Some(RefOr::Ref(reference))
151 if reference.ref_location == "#/components/schemas/ErrorResponse"
152 )
153}