1use std::sync::Arc;
17
18use serde_json::Value;
19
20pub use crate::config::{
21 LifecyclePortalConfig as DevPortalConfig, LifecyclePortalSpec as DevPortalSpec,
22};
23
24#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum DevPortalSpecSource {
28 File(String),
29 Url(String),
30}
31
32#[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#[derive(Debug, Clone)]
52pub struct DevPortal {
53 config: Arc<DevPortalConfig>,
54 file_specs: Arc<Vec<LoadedSpec>>,
55}
56
57impl DevPortal {
58 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 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 pub fn path(&self) -> &str {
92 &self.config.path
93 }
94
95 pub fn enabled(&self) -> bool {
97 self.config.enabled
98 }
99
100 pub fn file_specs(&self) -> &[LoadedSpec] {
102 &self.file_specs
103 }
104
105 pub fn render_html(&self) -> String {
109 let mut specs: Vec<LoadedSpec> = self.file_specs.to_vec();
110 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
132fn 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
147fn 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
193fn fetch_url_spec(url: &str) -> Result<(Value, String, String), String> {
198 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
237fn 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
259fn 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
271fn 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
285fn 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
293fn 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
303fn 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
378fn html_escape(s: &str) -> String {
380 s.replace('&', "&")
381 .replace('<', "<")
382 .replace('>', ">")
383 .replace('"', """)
384}