Skip to main content

lenso_openapi_plugin/
lib.rs

1//! Optional `OpenAPI` 3.1 document Plugin for explicitly bound HTTP Endpoints.
2//!
3//! Merely linking this crate changes no App. App Composition must select an
4//! Instance, bind the Endpoint descriptions to document, and bind this Plugin's
5//! own Endpoint to Web Ingress.
6
7mod assemble;
8mod config;
9
10use std::{cell::RefCell, rc::Rc};
11
12use lenso::prelude::ManyPort;
13use lenso::{ActivateContext, DeactivateContext, Lifecycle, provides};
14use lenso_capability_http_endpoint as http_endpoint;
15use lenso_capability_http_endpoint::{
16    DescribeError, DescribeRequest, DescribeResponse, DescribeResponseRoutesItem, EndpointDescribe,
17    EndpointHandle, EndpointProvider, HandleError, HandleRequest, HandleResponse,
18    HandleResponseHeadersItem,
19};
20use lenso_kernel::{InvocationContext, NativeRequestFuture, RuntimeFailure};
21use serde_json::json;
22
23pub use config::OpenApiConfig;
24
25pub const DOCUMENT_ROUTE_ID: &str = "lenso.openapi.document";
26
27fn validate_config(config: &OpenApiConfig) -> Result<(), RuntimeFailure> {
28    config
29        .validate()
30        .map_err(|detail| RuntimeFailure::InvalidResolvedPlan {
31            detail: format!("OpenAPI configuration is invalid: {detail}"),
32        })
33}
34
35#[lenso::plugin(
36    lifecycle,
37    validate = validate_config,
38    configuration_schema = "config.schema.json"
39)]
40#[derive(Clone, Debug)]
41struct OpenApiPlugin {
42    #[config]
43    config: OpenApiConfig,
44    endpoints: ManyPort<http_endpoint::EndpointClient>,
45    document: Rc<RefCell<Option<Vec<u8>>>>,
46}
47
48#[provides(http_endpoint::Endpoint)]
49impl EndpointProvider for OpenApiPlugin {
50    fn describe(
51        &self,
52        _context: InvocationContext,
53        _request: DescribeRequest,
54    ) -> NativeRequestFuture<EndpointDescribe> {
55        let route = DescribeResponseRoutesItem {
56            method: "GET".to_owned(),
57            openapi: Some(
58                json!({
59                    "summary": "Get the OpenAPI document",
60                    "responses": {
61                        "200": {
62                            "description": "OpenAPI 3.1 document",
63                            "content": {
64                                "application/json": {
65                                    "schema": {"type": "object"}
66                                }
67                            }
68                        }
69                    }
70                })
71                .as_object()
72                .expect("the OpenAPI operation is an object")
73                .clone()
74                .into_iter()
75                .collect(),
76            ),
77            path: self.config.document_path().to_owned(),
78            route_id: DOCUMENT_ROUTE_ID.to_owned(),
79        };
80        Box::pin(futures::future::ready(Ok(Ok(DescribeResponse {
81            routes: vec![route],
82        }))))
83    }
84
85    fn handle(
86        &self,
87        _context: InvocationContext,
88        request: HandleRequest,
89    ) -> NativeRequestFuture<EndpointHandle> {
90        let result = if request.route_id == DOCUMENT_ROUTE_ID {
91            self.document.borrow().clone().map_or_else(
92                || {
93                    Err(RuntimeFailure::Internal {
94                        detail: "OpenAPI document is unavailable before activation".to_owned(),
95                    })
96                },
97                |document| {
98                    Ok(Ok(HandleResponse {
99                        body: document.into(),
100                        headers: vec![HandleResponseHeadersItem {
101                            name: "content-type".to_owned(),
102                            value: "application/json; charset=utf-8".to_owned(),
103                        }],
104                        status: 200,
105                    }))
106                },
107            )
108        } else {
109            Ok(Err(HandleError::Rejected))
110        };
111        Box::pin(futures::future::ready(result))
112    }
113}
114
115#[allow(unknown_lints, clippy::unused_async_trait_impl)]
116impl Lifecycle for OpenApiPlugin {
117    async fn activate(&self, _context: ActivateContext) -> Result<(), RuntimeFailure> {
118        let mut descriptions = Vec::with_capacity(self.endpoints.len());
119        for (provider_index, endpoint) in self.endpoints.iter().enumerate() {
120            let description = endpoint
121                .describe(DescribeRequest {})
122                .await
123                .map_err(|error| match error {
124                    http_endpoint::EndpointDescribeInvocationError::Domain(error) => {
125                        describe_failure(provider_index, &error)
126                    }
127                    http_endpoint::EndpointDescribeInvocationError::Runtime(error) => error,
128                })?;
129            descriptions.push(description);
130        }
131        let assembled = assemble::assemble(&self.config, descriptions).map_err(plugin_failure)?;
132        self.document.borrow_mut().replace(assembled);
133        Ok(())
134    }
135
136    async fn deactivate(&self, _context: DeactivateContext) -> Result<(), RuntimeFailure> {
137        self.document.borrow_mut().take();
138        Ok(())
139    }
140}
141
142fn describe_failure(provider_index: usize, error: &DescribeError) -> RuntimeFailure {
143    plugin_failure(format!(
144        "OpenAPI Endpoint provider {provider_index} rejected its description: {error:?}"
145    ))
146}
147
148fn plugin_failure(detail: impl Into<String>) -> RuntimeFailure {
149    RuntimeFailure::PluginFailure {
150        detail: detail.into(),
151    }
152}