dwara_core/lifecycle/
portal.rs

1//! Developer portal (DW-110): a read-only static HTML page
2//! auto-generated from the configured OpenAPI spec sources.
3//!
4//! The portal aggregates the existing OpenAPI specs (file paths or
5//! upstream `/openapi.json` endpoints) into a single listing of the
6//! APIs, their versions, and links to the specs. It is served at a
7//! configured reserved path (before route resolution, like `/healthz`).
8//! The portal is read-only (no CRUD): it renders the specs the operator
9//! already configured, it does not manage them.
10//!
11//! The config schema types ([`DevPortalConfig`], [`DevPortalSpec`])
12//! live in [`crate::config`] (always present, so configs round-trip
13//! without the `api_lifecycle` feature). This module re-exports them
14//! as the runtime-facing aliases the portal builder consumes.
15
16use std::sync::Arc;
17
18use serde_json::Value;
19
20pub use crate::config::{
21    LifecyclePortalConfig as DevPortalConfig, LifecyclePortalSpec as DevPortalSpec,
22};
23
24/// The source kind of a developer portal spec (resolved from
25/// [`DevPortalSpec`]).
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum DevPortalSpecSource {
28    File(String),
29    Url(String),
30}
31
32/// A loaded OpenAPI spec entry for the portal: the source, the parsed
33/// document (when loading succeeded), and the display name.
34#[derive(Debug, Clone)]
35pub struct LoadedSpec {
36    pub source: DevPortalSpecSource,
37    pub doc: Option<Value>,
38    pub name: String,
39    pub version: String,
40    pub error: Option<String>,
41}
42
43/// The developer portal: a read-only static HTML page aggregating the
44/// configured OpenAPI specs.
45///
46/// Built at config publish time from the configured spec sources. The
47/// portal renders a simple HTML page listing the APIs, their versions,
48/// and links to the specs. File specs are loaded (read + parsed) at
49/// build time; URL specs are fetched lazily at render time (the
50/// upstream may be down at publish time but up at render time).
51#[derive(Debug, Clone)]
52pub struct DevPortal {
53    config: Arc<DevPortalConfig>,
54    file_specs: Arc<Vec<LoadedSpec>>,
55}
56
57impl DevPortal {
58    /// Build the portal from the configured spec sources. File specs
59    /// are loaded (read + parsed) immediately; URL specs are deferred
60    /// to render time. A file that cannot be read or parsed is recorded
61    /// as an error entry (the portal still renders, listing the spec as
62    /// unavailable) -- the portal is best-effort, never a hard failure.
63    pub fn build(config: &DevPortalConfig) -> Self {
64        let mut file_specs = Vec::new();
65        for spec in &config.specs {
66            let source = resolve_source(spec);
67            match &source {
68                DevPortalSpecSource::File(path) => {
69                    file_specs.push(load_file_spec(path, spec.name.as_deref()));
70                }
71                DevPortalSpecSource::Url(_) => {
72                    // Deferred to render time; record a placeholder so
73                    // the listing shows the spec even before fetch.
74                    file_specs.push(LoadedSpec {
75                        source,
76                        doc: None,
77                        name: spec.name.clone().unwrap_or_default(),
78                        version: String::new(),
79                        error: None,
80                    });
81                }
82            }
83        }
84        Self {
85            config: Arc::new(config.clone()),
86            file_specs: Arc::new(file_specs),
87        }
88    }
89
90    /// The configured path the portal is served at.
91    pub fn path(&self) -> &str {
92        &self.config.path
93    }
94
95    /// Whether the portal is enabled.
96    pub fn enabled(&self) -> bool {
97        self.config.enabled
98    }
99
100    /// The file-backed specs loaded at build time.
101    pub fn file_specs(&self) -> &[LoadedSpec] {
102        &self.file_specs
103    }
104
105    /// Render the portal as an HTML page. File specs use the documents
106    /// loaded at build time; URL specs are fetched at render time (a
107    /// fetch failure is logged and the spec is listed as unavailable).
108    pub fn render_html(&self) -> String {
109        let mut specs: Vec<LoadedSpec> = self.file_specs.to_vec();
110        // Fetch URL specs at render time.
111        for spec in specs.iter_mut() {
112            if let DevPortalSpecSource::Url(url) = &spec.source {
113                match fetch_url_spec(url) {
114                    Ok((doc, name, version)) => {
115                        spec.doc = Some(doc);
116                        spec.name = name;
117                        spec.version = version;
118                    }
119                    Err(e) => {
120                        spec.error = Some(e);
121                        if spec.name.is_empty() {
122                            spec.name = url_host(url).unwrap_or_else(|| url.clone());
123                        }
124                    }
125                }
126            }
127        }
128        render_portal_html(&specs)
129    }
130}
131
132/// Resolve a [`DevPortalSpec`] into a [`DevPortalSpecSource`]. Returns
133/// `File` when `file` is set, `Url` when `url` is set. When both are
134/// set, `file` wins (validation rejects the both-set case, but this is
135/// defensive). When neither is set, returns `File` with an empty path
136/// (validation rejects the neither-set case).
137fn resolve_source(spec: &DevPortalSpec) -> DevPortalSpecSource {
138    if let Some(file) = &spec.file {
139        return DevPortalSpecSource::File(file.clone());
140    }
141    if let Some(url) = &spec.url {
142        return DevPortalSpecSource::Url(url.clone());
143    }
144    DevPortalSpecSource::File(String::new())
145}
146
147/// Load a file-backed OpenAPI spec: read the file, parse it as JSON,
148/// extract the title and version. A read or parse failure is recorded
149/// as an error entry (the portal still renders, listing the spec as
150/// unavailable).
151fn load_file_spec(path: &str, override_name: Option<&str>) -> LoadedSpec {
152    let source = DevPortalSpecSource::File(path.to_string());
153    let content = match std::fs::read_to_string(path) {
154        Ok(c) => c,
155        Err(e) => {
156            return LoadedSpec {
157                source,
158                doc: None,
159                name: override_name
160                    .map(|s| s.to_string())
161                    .unwrap_or_else(|| file_stem(path).unwrap_or_else(|| path.to_string())),
162                version: String::new(),
163                error: Some(format!("read failed: {e}")),
164            };
165        }
166    };
167    match serde_json::from_str::<Value>(&content) {
168        Ok(doc) => {
169            let (name, version) = extract_title_version(&doc);
170            LoadedSpec {
171                source,
172                doc: Some(doc),
173                name: override_name
174                    .map(|s| s.to_string())
175                    .or(name)
176                    .unwrap_or_else(|| file_stem(path).unwrap_or_else(|| path.to_string())),
177                version: version.unwrap_or_default(),
178                error: None,
179            }
180        }
181        Err(e) => LoadedSpec {
182            source,
183            doc: None,
184            name: override_name
185                .map(|s| s.to_string())
186                .unwrap_or_else(|| file_stem(path).unwrap_or_else(|| path.to_string())),
187            version: String::new(),
188            error: Some(format!("parse failed: {e}")),
189        },
190    }
191}
192
193/// Fetch a URL-backed OpenAPI spec at render time. Uses a blocking HTTP
194/// GET (the portal render path is not the request hot path -- it is an
195/// operator-facing static page). Returns the parsed document plus the
196/// derived name and version.
197fn fetch_url_spec(url: &str) -> Result<(Value, String, String), String> {
198    // The portal render path is operator-facing and off the request hot
199    // path; a blocking std HTTP client would need a dep, so we reuse
200    // the std library's minimal HTTP/1.1 client via TcpStream. This is
201    // deliberately minimal: no redirects, no TLS (http only here; https
202    // URL specs are fetched by the dataplane's upstream client in a
203    // future enhancement -- for now the portal lists them with a note).
204    if url.starts_with("https://") {
205        return Err(
206            "https URL specs are not fetched by the portal renderer yet \
207             (list the spec with its name/version from a file copy, or \
208             expose the spec over http)"
209                .to_string(),
210        );
211    }
212    let (host, port, path) = parse_http_url(url)?;
213    use std::io::{Read, Write};
214    use std::net::TcpStream;
215    let mut stream =
216        TcpStream::connect((host.as_str(), port)).map_err(|e| format!("connect failed: {e}"))?;
217    let req = format!(
218        "GET {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\nAccept: application/json\r\n\r\n"
219    );
220    stream
221        .write_all(req.as_bytes())
222        .map_err(|e| format!("write failed: {e}"))?;
223    let mut buf = Vec::new();
224    stream
225        .read_to_end(&mut buf)
226        .map_err(|e| format!("read failed: {e}"))?;
227    let body = extract_http_body(&buf)?;
228    let doc: Value = serde_json::from_str(&body).map_err(|e| format!("parse failed: {e}"))?;
229    let (name, version) = extract_title_version(&doc);
230    Ok((
231        doc,
232        name.unwrap_or_else(|| host.clone()),
233        version.unwrap_or_default(),
234    ))
235}
236
237/// Parse an `http://host[:port]/path` URL into (host, port, path).
238fn parse_http_url(url: &str) -> Result<(String, u16, String), String> {
239    let rest = url
240        .strip_prefix("http://")
241        .ok_or_else(|| "not an http URL".to_string())?;
242    let (authority, path) = match rest.find('/') {
243        Some(i) => (&rest[..i], &rest[i..]),
244        None => (rest, "/"),
245    };
246    let (host, port) = match authority.find(':') {
247        Some(i) => {
248            let h = &authority[..i];
249            let p: u16 = authority[i + 1..]
250                .parse()
251                .map_err(|_| "invalid port".to_string())?;
252            (h.to_string(), p)
253        }
254        None => (authority.to_string(), 80),
255    };
256    Ok((host, port, path.to_string()))
257}
258
259/// Extract the HTTP body from a raw response buffer (split on the
260/// blank-line header/body separator).
261fn extract_http_body(buf: &[u8]) -> Result<String, String> {
262    let sep = b"\r\n\r\n";
263    let idx = buf
264        .windows(4)
265        .position(|w| w == sep)
266        .ok_or_else(|| "no header/body separator".to_string())?;
267    let body = &buf[idx + 4..];
268    String::from_utf8(body.to_vec()).map_err(|e| format!("body not utf-8: {e}"))
269}
270
271/// Extract the `info.title` and `info.version` from an OpenAPI doc.
272fn extract_title_version(doc: &Value) -> (Option<String>, Option<String>) {
273    let info = doc.get("info");
274    let title = info
275        .and_then(|i| i.get("title"))
276        .and_then(|t| t.as_str())
277        .map(|s| s.to_string());
278    let version = info
279        .and_then(|i| i.get("version"))
280        .and_then(|v| v.as_str())
281        .map(|s| s.to_string());
282    (title, version)
283}
284
285/// The file stem (basename without extension) for a fallback display
286/// name.
287fn file_stem(path: &str) -> Option<String> {
288    let base = path.rsplit('/').next()?;
289    let stem = base.rsplit_once('.').map(|(s, _)| s).unwrap_or(base);
290    Some(stem.to_string())
291}
292
293/// The host portion of a URL (for a fallback display name).
294fn url_host(url: &str) -> Option<String> {
295    let rest = url
296        .strip_prefix("http://")
297        .or_else(|| url.strip_prefix("https://"))?;
298    let authority = rest.split('/').next()?;
299    let host = authority.split(':').next()?;
300    Some(host.to_string())
301}
302
303/// Render the portal HTML page from the loaded specs.
304fn render_portal_html(specs: &[LoadedSpec]) -> String {
305    let mut rows = String::new();
306    if specs.is_empty() {
307        rows.push_str(
308            "      <tr><td colspan=\"4\" class=\"empty\">No APIs configured yet. \
309             Add OpenAPI spec sources to the <code>lifecycle.portal.specs</code> \
310             config block.</td></tr>\n",
311        );
312    } else {
313        for spec in specs {
314            let status = if let Some(e) = &spec.error {
315                format!("<span class=\"error\">unavailable: {e}</span>")
316            } else {
317                "<span class=\"ok\">available</span>".to_string()
318            };
319            let link = match &spec.source {
320                DevPortalSpecSource::File(p) => {
321                    format!("<code>{}</code>", html_escape(p))
322                }
323                DevPortalSpecSource::Url(u) => {
324                    format!(
325                        "<a href=\"{}\" target=\"_blank\">{}</a>",
326                        html_escape(u),
327                        html_escape(u)
328                    )
329                }
330            };
331            rows.push_str(&format!(
332                "      <tr>\n        <td>{}</td>\n        <td>{}</td>\n        \
333                 <td>{}</td>\n        <td>{}</td>\n      </tr>\n",
334                html_escape(&spec.name),
335                html_escape(&spec.version),
336                link,
337                status,
338            ));
339        }
340    }
341    format!(
342        "<!DOCTYPE html>\n\
343<html lang=\"en\">\n\
344<head>\n\
345  <meta charset=\"utf-8\">\n\
346  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\
347  <title>dwara developer portal</title>\n\
348  <style>\n\
349    body {{ font-family: system-ui, sans-serif; margin: 2rem; color: #222; }}\n\
350    h1 {{ font-size: 1.5rem; }}\n\
351    table {{ border-collapse: collapse; width: 100%; }}\n\
352    th, td {{ border: 1px solid #ddd; padding: 0.5rem; text-align: left; }}\n\
353    th {{ background: #f5f5f5; }}\n\
354    .empty {{ color: #888; font-style: italic; }}\n\
355    .ok {{ color: #2a7d2a; }}\n\
356    .error {{ color: #b00; }}\n\
357    code {{ background: #f5f5f5; padding: 0.1rem 0.3rem; border-radius: 3px; }}\n\
358  </style>\n\
359</head>\n\
360<body>\n\
361  <h1>dwara developer portal</h1>\n\
362  <p>The APIs the gateway fronts, aggregated from the configured OpenAPI \
363specs. This is a read-only listing -- spec management is via config.</p>\n\
364  <table>\n\
365    <thead>\n\
366      <tr><th>API</th><th>Version</th><th>Spec</th><th>Status</th></tr>\n\
367    </thead>\n\
368    <tbody>\n\
369{rows}\
370    </tbody>\n\
371  </table>\n\
372</body>\n\
373</html>\n",
374        rows = rows,
375    )
376}
377
378/// HTML-escape a string for safe interpolation into the portal page.
379fn html_escape(s: &str) -> String {
380    s.replace('&', "&amp;")
381        .replace('<', "&lt;")
382        .replace('>', "&gt;")
383        .replace('"', "&quot;")
384}