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 (name = "system-delivery", description = "Production delivery authority APIs")
36 )
37)]
38struct ApiDoc;
39
40pub(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#[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}