bbox_core/
service.rs

1use crate::api::{OgcApiInventory, OpenApiDoc};
2use crate::auth::oidc::OidcClient;
3use crate::cli::{CliArgs, CommonCommands, GlobalArgs, NoArgs, NoCommands};
4use crate::config::{ConfigError, CoreServiceCfg, WebserverCfg};
5use crate::logger;
6use crate::metrics::{init_metrics_exporter, no_metrics, NoMetrics};
7use crate::ogcapi::{ApiLink, CoreCollection};
8use crate::tls::load_rustls_config;
9use actix_cors::Cors;
10use actix_session::{config::PersistentSession, storage::CookieSessionStore, SessionMiddleware};
11use actix_web::{
12    cookie::{time::Duration, Key},
13    middleware,
14    middleware::Condition,
15    web, App, HttpServer,
16};
17use actix_web_opentelemetry::{RequestMetrics, RequestMetricsBuilder, RequestTracing};
18use async_trait::async_trait;
19use clap::{ArgMatches, Args, Parser, Subcommand};
20use log::info;
21use once_cell::sync::OnceCell;
22use opentelemetry_prometheus::PrometheusExporter;
23use prometheus::Registry;
24
25pub trait ServiceConfig: Sized {
26    /// Initialize service config from config files, environment variables and cli args
27    fn initialize(cli: &ArgMatches) -> Result<Self, ConfigError>;
28}
29
30#[async_trait]
31pub trait OgcApiService: Clone + Send {
32    type Config: ServiceConfig;
33    type CliCommands: Subcommand + Parser + core::fmt::Debug;
34    type CliArgs: Args + core::fmt::Debug;
35    type Metrics;
36
37    /// Create service from config
38    async fn create(cfg: &Self::Config, core_cfg: &CoreServiceCfg) -> Self;
39    fn landing_page_links(&self, _api_base: &str) -> Vec<ApiLink> {
40        Vec::new()
41    }
42    fn conformance_classes(&self) -> Vec<String> {
43        Vec::new()
44    }
45    fn collections(&self) -> Vec<CoreCollection> {
46        Vec::new()
47    }
48    fn openapi_yaml(&self) -> Option<&str> {
49        None
50    }
51    /// Service metrics
52    fn metrics(&self) -> &'static Self::Metrics;
53    /// Add metrics to Prometheus registry
54    fn add_metrics(&self, _prometheus: &Registry) {}
55    async fn cli_run(&self, _cli: &ArgMatches) -> bool {
56        false
57    }
58}
59
60pub trait ServiceEndpoints {
61    fn register_endpoints(&self, cfg: &mut web::ServiceConfig);
62}
63
64#[derive(Clone)]
65pub struct DummyService;
66
67#[derive(Clone)]
68pub struct NoConfig;
69
70impl ServiceConfig for NoConfig {
71    fn initialize(_args: &ArgMatches) -> Result<Self, ConfigError> {
72        Ok(NoConfig)
73    }
74}
75
76#[async_trait]
77impl OgcApiService for DummyService {
78    type Config = NoConfig;
79    type CliCommands = NoCommands;
80    type CliArgs = NoArgs;
81    type Metrics = NoMetrics;
82
83    async fn create(_cfg: &Self::Config, _core_cfg: &CoreServiceCfg) -> Self {
84        DummyService
85    }
86    fn metrics(&self) -> &'static Self::Metrics {
87        no_metrics()
88    }
89}
90
91impl ServiceEndpoints for DummyService {
92    fn register_endpoints(&self, _cfg: &mut web::ServiceConfig) {}
93}
94
95#[derive(Clone)]
96pub struct CoreService {
97    pub web_config: WebserverCfg,
98    pub(crate) ogcapi: OgcApiInventory,
99    pub(crate) openapi: OpenApiDoc,
100    pub(crate) metrics: Option<PrometheusExporter>,
101    pub(crate) oidc: Option<OidcClient>,
102}
103
104impl CoreService {
105    pub fn add_service<T: OgcApiService>(&mut self, svc: &T) {
106        let api_base = "";
107
108        self.ogcapi
109            .landing_page_links
110            .extend(svc.landing_page_links(api_base));
111        self.ogcapi
112            .conformance_classes
113            .extend(svc.conformance_classes());
114        self.ogcapi.collections.extend(svc.collections());
115
116        if let Some(yaml) = svc.openapi_yaml() {
117            if self.openapi.is_empty() {
118                self.openapi = OpenApiDoc::from_yaml(yaml, api_base);
119            } else {
120                self.openapi.extend(yaml, api_base);
121            }
122        }
123
124        if let Some(metrics) = &self.metrics {
125            svc.add_metrics(metrics.registry())
126        }
127    }
128    pub fn has_cors(&self) -> bool {
129        self.web_config.cors.is_some()
130    }
131    pub fn cors(&self) -> Cors {
132        if let Some(cors_cfg) = self.web_config.cors.as_ref() {
133            let mut cors = Cors::default().allowed_methods(vec!["GET"]);
134            if cors_cfg.allow_all_origins {
135                cors = cors.allow_any_origin().send_wildcard();
136            }
137            cors
138        } else {
139            Cors::default()
140        }
141    }
142    pub fn has_metrics(&self) -> bool {
143        self.metrics.is_some()
144    }
145    /// Request tracing middleware
146    pub fn middleware(&self) -> RequestTracing {
147        RequestTracing::new()
148    }
149    pub fn workers(&self) -> usize {
150        self.web_config.worker_threads()
151    }
152    pub fn tls_config(&self) -> Option<rustls::ServerConfig> {
153        if let Some(cert) = &self.web_config.tls_cert {
154            if let Some(key) = &self.web_config.tls_key {
155                return Some(load_rustls_config(cert, key));
156            }
157        }
158        None
159    }
160    pub fn server_addr(&self) -> &str {
161        &self.web_config.server_addr
162    }
163}
164
165#[async_trait]
166impl OgcApiService for CoreService {
167    type Config = CoreServiceCfg;
168    type CliCommands = CommonCommands;
169    type CliArgs = GlobalArgs;
170    type Metrics = RequestMetrics;
171
172    async fn create(cfg: &Self::Config, _core_cfg: &CoreServiceCfg) -> Self {
173        logger::init(cfg.loglevel());
174        let metrics = init_metrics_exporter();
175        let oidc = if let Some(auth_cfg) = &cfg.auth {
176            if let Some(oidc_cfg) = &auth_cfg.oidc {
177                Some(OidcClient::from_config(oidc_cfg).await)
178            } else {
179                None
180            }
181        } else {
182            None
183        };
184        CoreService {
185            web_config: cfg.webserver.clone().unwrap_or_default(),
186            ogcapi: OgcApiInventory::default(),
187            openapi: OpenApiDoc::new(),
188            metrics,
189            oidc,
190        }
191    }
192    fn landing_page_links(&self, _api_base: &str) -> Vec<ApiLink> {
193        vec![
194            ApiLink {
195                href: "/".to_string(),
196                rel: Some("self".to_string()),
197                type_: Some("application/json".to_string()),
198                title: Some("this document".to_string()),
199                hreflang: None,
200                length: None,
201            },
202            ApiLink {
203                href: "/openapi.json".to_string(),
204                rel: Some("service-desc".to_string()),
205                type_: Some("application/vnd.oai.openapi+json;version=3.0".to_string()),
206                title: Some("the API definition".to_string()),
207                hreflang: None,
208                length: None,
209            },
210            ApiLink {
211                href: "/openapi.yaml".to_string(),
212                rel: Some("service-desc".to_string()),
213                type_: Some("application/x-yaml".to_string()),
214                title: Some("the API definition".to_string()),
215                hreflang: None,
216                length: None,
217            },
218            ApiLink {
219                href: "/conformance".to_string(),
220                rel: Some("conformance".to_string()),
221                type_: Some("application/json".to_string()),
222                title: Some("OGC API conformance classes implemented by this server".to_string()),
223                hreflang: None,
224                length: None,
225            },
226        ]
227    }
228    fn conformance_classes(&self) -> Vec<String> {
229        vec![
230            "http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/core".to_string(),
231            // "http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/oas30".to_string(),
232        ]
233    }
234    fn openapi_yaml(&self) -> Option<&str> {
235        Some(include_str!("openapi.yaml"))
236    }
237    fn metrics(&self) -> &'static Self::Metrics {
238        static METRICS: OnceCell<RequestMetrics> = OnceCell::new();
239        METRICS.get_or_init(|| {
240            RequestMetricsBuilder::new().build(opentelemetry::global::meter("bbox"))
241        })
242    }
243}
244
245/// Generic main method for a single OgcApiService
246#[actix_web::main]
247pub async fn run_service<T: OgcApiService + ServiceEndpoints + Sync + 'static>(
248) -> std::io::Result<()> {
249    let mut cli = CliArgs::default();
250    cli.register_service_args::<CoreService>();
251    cli.register_service_args::<T>();
252    cli.apply_global_args();
253    let matches = cli.cli_matches();
254
255    let core_cfg = CoreServiceCfg::initialize(&matches).unwrap();
256    let mut core = CoreService::create(&core_cfg, &core_cfg).await;
257
258    let service_cfg = T::Config::initialize(&matches).unwrap();
259    let service = T::create(&service_cfg, &core_cfg).await;
260
261    core.add_service(&service);
262
263    if service.cli_run(&matches).await {
264        return Ok(());
265    }
266
267    let secret_key = Key::generate();
268    let session_ttl = Duration::minutes(1);
269
270    let workers = core.workers();
271    let server_addr = core.server_addr().to_string();
272    let tls_config = core.tls_config();
273    let mut server = HttpServer::new(move || {
274        App::new()
275            .configure(|cfg| core.register_endpoints(cfg))
276            .configure(|cfg| service.register_endpoints(cfg))
277            .wrap(
278                SessionMiddleware::builder(CookieSessionStore::default(), secret_key.clone())
279                    .cookie_name("bbox".to_owned())
280                    .cookie_secure(false)
281                    .session_lifecycle(PersistentSession::default().session_ttl(session_ttl))
282                    .build(),
283            )
284            .wrap(Condition::new(core.has_cors(), core.cors()))
285            .wrap(middleware::Compress::default())
286            .wrap(middleware::NormalizePath::trim())
287            .wrap(middleware::Logger::default())
288    });
289    if let Some(tls_config) = tls_config {
290        info!("Starting web server at https://{server_addr}");
291        server = server.bind_rustls(server_addr, tls_config)?;
292    } else {
293        info!("Starting web server at http://{server_addr}");
294        server = server.bind(server_addr)?;
295    }
296    server.workers(workers).run().await
297}