1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
//! The start-up banner.
//!
//! Everything else the server says on boot goes through `tracing`, one line per
//! thing it loaded. The banner is the exception: it is the *address book* the
//! user needs the moment the server is up — where the API is, where the docs
//! are, where the dashboard is — so it is printed to stdout as a block, after
//! the log lines, rather than buried among them.
//!
//! The drawing itself lives in [`crate::term`], which every other command
//! prints through too.
//!
//! When `[server] domain` names one or more hosts the server only answers those
//! hosts, so there is a box per domain, each with that domain's own links. With
//! no domain configured the server answers for any host and there is one box,
//! titled with the address it is bound to.
use crate::term;
/// What the banner needs to know about the app it is announcing.
pub(crate) struct Banner {
/// The app's display name, printed above the boxes.
pub name: String,
/// `http` or `https`.
pub scheme: &'static str,
/// The bound `host:port`, used when no domain is configured.
pub addr: String,
/// The API mount point, e.g. `/api` (may be empty for the root).
pub base_path: String,
/// The docs path relative to `base_path`, when docs are enabled.
pub docs_path: Option<String>,
/// The dashboard path, e.g. `/admin`, when it is being served.
pub admin_path: Option<String>,
/// Whether the app's `public/` site is served at the root.
pub site: bool,
/// Hosts the server answers for, empty when it answers for any.
pub domains: Vec<String>,
}
impl Banner {
/// Print the wordmark, the app's name, and one box per host.
pub(crate) fn print(&self) {
term::wordmark();
term::heading(&self.name, None);
// With no domain configured the server answers for any host, so the
// single box is titled with the address it is bound to instead.
if self.domains.is_empty() {
term::links(&self.addr, &self.links(&self.addr));
} else {
for domain in &self.domains {
term::links(domain, &self.links(domain));
}
}
println!();
}
/// The labelled links for one host, in the order they are printed.
fn links(&self, host: &str) -> Vec<(&'static str, String)> {
let base = format!("{}://{host}{}", self.scheme, self.base_path);
let mut links = vec![("API", base.clone())];
if let Some(docs) = &self.docs_path {
links.push(("Docs", format!("{base}{docs}")));
}
if let Some(admin) = &self.admin_path {
links.push(("Admin", format!("{}://{host}{admin}/", self.scheme)));
}
if self.site {
links.push(("Site", format!("{}://{host}/", self.scheme)));
}
links
}
}
#[cfg(test)]
mod tests {
use super::*;
fn banner() -> Banner {
Banner {
name: "Acme API".into(),
scheme: "http",
addr: "0.0.0.0:8099".into(),
base_path: "/api".into(),
docs_path: Some("/docs".into()),
admin_path: Some("/admin".into()),
site: true,
domains: vec![],
}
}
#[test]
fn links_are_built_from_the_host_and_the_mount_point() {
let b = banner();
let links = b.links("api.example.test");
assert_eq!(
links,
vec![
("API", "http://api.example.test/api".to_string()),
("Docs", "http://api.example.test/api/docs".to_string()),
("Admin", "http://api.example.test/admin/".to_string()),
("Site", "http://api.example.test/".to_string()),
]
);
}
#[test]
fn disabled_docs_and_admin_are_left_out() {
let mut b = banner();
b.docs_path = None;
b.admin_path = None;
b.site = false;
let links = b.links("localhost:8099");
assert_eq!(links.len(), 1);
assert_eq!(links[0].0, "API");
}
#[test]
fn printing_a_domain_box_does_not_panic_on_a_short_domain() {
let mut b = banner();
b.domains = vec!["a.io".into()];
b.print();
}
}