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