Skip to content

Commit 5a9de19

Browse files
authored
Merge pull request #12 from CosmicHorrorDev/example
docs: Add an interactive example
2 parents 8124846 + d06d6a4 commit 5a9de19

File tree

2 files changed

+276
-0
lines changed

2 files changed

+276
-0
lines changed

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ reqwest = { version = "0.12", default-features = false, optional = true }
2222
time = { version = "0.3.20", features = ["parsing", "formatting"] }
2323

2424
[dev-dependencies]
25+
dialoguer = "0.11.0"
2526
serde_json = "1.0.108"
2627

2728
[features]

examples/interactive.rs

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
//! A command-line interactive HTTP cache demo
2+
//!
3+
//! All of the `http_cache_semantics` logic is contained entirely within `fn make_a_request()`
4+
5+
use std::{collections::HashMap, sync::{LazyLock, Mutex}, time::{Duration, SystemTime}};
6+
7+
use dialoguer::{console::style, theme::ColorfulTheme, Input};
8+
use http::{Response, Request, Uri};
9+
use http_cache_semantics::{CacheOptions, CachePolicy};
10+
11+
const START: SystemTime = SystemTime::UNIX_EPOCH;
12+
static CURRENT_TIME: Mutex<SystemTime> = Mutex::new(START);
13+
static THEME: LazyLock<ColorfulTheme> = LazyLock::new(ColorfulTheme::default);
14+
15+
type Req = Request<()>;
16+
type Body = String;
17+
type Resp = Response<Body>;
18+
type Cache = HashMap<Uri, (CachePolicy, Body)>;
19+
20+
fn main() {
21+
// handle cli args
22+
let mut args = std::env::args();
23+
let _exe = args.next().unwrap();
24+
let has_private_flag = match args.next().as_deref() {
25+
None => {
26+
println!(
27+
"running as a {}. pass {} to run as a private cache",
28+
bold("shared cache").magenta(),
29+
style("`-- --private-cache`").dim().italic(),
30+
);
31+
false
32+
}
33+
Some("-p" | "--private-cache") => {
34+
println!("running as a {}", bold("private cache").blue());
35+
true
36+
}
37+
_ => {
38+
eprintln!("usage: cargo run --example=interactive -- [-p|--private-cache]");
39+
std::process::exit(1);
40+
}
41+
};
42+
43+
let cache_options = CacheOptions {
44+
shared: !has_private_flag,
45+
..Default::default()
46+
};
47+
let mut cache = Cache::new();
48+
let items = ["make a request", "advance time", "list cache entries", "quit"];
49+
loop {
50+
println!("{} {}", bold("current time:"), style(current_m_ss()).green());
51+
let selection = select_prompt()
52+
.with_prompt("pick an action")
53+
.items(&items)
54+
.interact()
55+
.unwrap();
56+
match selection {
57+
0 => make_a_request(&mut cache, cache_options),
58+
1 => advance_time(),
59+
2 => list_cache_entries(&cache),
60+
3 => break,
61+
_ => unreachable!(),
62+
}
63+
}
64+
65+
println!("\n...and a peek at the cache to finish things off. goodbye!");
66+
list_cache_entries(&cache);
67+
}
68+
69+
fn make_a_request(cache: &mut Cache, cache_options: CacheOptions) {
70+
use std::collections::hash_map::Entry;
71+
72+
use http_cache_semantics::{AfterResponse, BeforeRequest};
73+
74+
let req = setup_req();
75+
let resp = match cache.entry(req.uri().to_owned()) {
76+
Entry::Occupied(mut occupied) => {
77+
let (policy, body) = occupied.get();
78+
match policy.before_request(&req, current_time()) {
79+
BeforeRequest::Fresh(resp) => {
80+
println!("{} retrieving cached response", bold("fresh cache entry!").green());
81+
Resp::from_parts(resp, body.to_owned())
82+
},
83+
BeforeRequest::Stale { request, .. } => {
84+
println!("{}", bold("stale entry!").red());
85+
let new_req = Req::from_parts(request, ());
86+
let mut resp = server::get(new_req.clone());
87+
let after_resp = policy.after_response(&new_req, &resp, current_time());
88+
let (not_modified, new_policy, new_resp) = match after_resp {
89+
AfterResponse::NotModified(p, r) => (true, p, r),
90+
AfterResponse::Modified(p, r) => (false, p, r),
91+
};
92+
// NOTE: if the policy isn't storable then you MUST NOT store the entry
93+
if new_policy.is_storable() {
94+
if not_modified {
95+
println!("{} only updating metadata", bold("not modified!").blue());
96+
let entry = occupied.get_mut();
97+
entry.0 = new_policy;
98+
// and reconstruct the response from our cached bits
99+
resp = Resp::from_parts(new_resp, entry.1.clone());
100+
} else {
101+
println!("{} updating full entry", bold("modified!").magenta());
102+
occupied.insert((new_policy, resp.body().to_owned()));
103+
}
104+
} else {
105+
println!(
106+
"{} entry was not considered storable",
107+
bold("skipping cache!").red(),
108+
);
109+
}
110+
resp
111+
}
112+
}
113+
}
114+
Entry::Vacant(vacant) => {
115+
let resp = server::get(req.clone());
116+
let policy = CachePolicy::new_options(&req, &resp, current_time(), cache_options);
117+
// NOTE: if the policy isn't storable then you MUST NOT store the entry
118+
if policy.is_storable() {
119+
println!("{} inserting entry", bold("cached!").green());
120+
let body = resp.body().to_owned();
121+
vacant.insert((policy, body));
122+
} else {
123+
println!("{} entry was not considered storable", bold("skipping cache!").red());
124+
}
125+
resp
126+
}
127+
};
128+
129+
println!("\n{} {} {}", bold("response from -"), bold("GET").green(), style(req.uri()).green());
130+
println!("{}", bold("headers -").blue());
131+
for (name, value) in resp.headers() {
132+
println!("{}: {}", bold(name.as_str()).blue(), style(value.to_str().unwrap()).italic());
133+
}
134+
println!("{} {}\n", bold("body -").blue(), style(resp.body()).blue());
135+
}
136+
137+
fn advance_time() {
138+
let seconds: u64 = Input::with_theme(&*THEME)
139+
.with_prompt("seconds to advance")
140+
.interact()
141+
.unwrap();
142+
*CURRENT_TIME.lock().unwrap() += Duration::from_secs(seconds);
143+
println!("{} {}", bold("advanced to:"), style(current_m_ss()).green());
144+
}
145+
146+
fn list_cache_entries(cache: &Cache) {
147+
println!();
148+
for (uri, (policy, body)) in cache {
149+
let (stale_msg, ttl) = if policy.is_stale(current_time()) {
150+
(bold("stale").magenta(), style("ttl - expired".to_owned()).italic())
151+
} else {
152+
let ttl = format!("ttl - {:>7?}", policy.time_to_live(current_time()));
153+
(bold("fresh").blue(), bold(ttl))
154+
};
155+
let get = bold("GET").green();
156+
let uri = style(uri.to_string()).green();
157+
let body = style(body).blue();
158+
println!("{stale_msg} {ttl} {get} {uri:23} | {body}");
159+
}
160+
println!();
161+
}
162+
163+
use helpers::{bold, current_duration, current_time, current_m_ss, select_prompt, setup_req};
164+
mod helpers {
165+
use std::time::{Duration, SystemTime};
166+
167+
use super::{CURRENT_TIME, START, THEME, Req};
168+
169+
use dialoguer::{console::{style, StyledObject}, Select, };
170+
171+
pub fn select_prompt() -> Select<'static> {
172+
Select::with_theme(&*THEME)
173+
.default(0)
174+
}
175+
176+
pub fn bold<D>(d: D) -> StyledObject<D> {
177+
style(d).bold()
178+
}
179+
180+
pub fn current_time() -> SystemTime {
181+
*CURRENT_TIME.lock().unwrap()
182+
}
183+
184+
pub fn current_duration() -> Duration {
185+
current_time().duration_since(START).unwrap()
186+
}
187+
188+
pub fn current_m_ss() -> String {
189+
let elapsed = current_duration();
190+
let mins = elapsed.as_secs() / 60;
191+
let secs = elapsed.as_secs() % 60;
192+
format!("{mins}m{secs:02}s")
193+
}
194+
195+
pub fn setup_req() -> Req {
196+
let path_to_cache_desc = [
197+
("/current-time", "no-store"),
198+
("/cached-current-time", "max-age: 10s"),
199+
("/friends-online", "private, max-age: 30s"),
200+
("/user/123/profile-pic", "e-tag w/ max-age: 30s"),
201+
("/cache-busted-123B-8E2A", "immutable"),
202+
];
203+
let styled: Vec<_> = path_to_cache_desc
204+
.iter()
205+
.map(|(path, cache_desc)| {
206+
format!(
207+
"{} {:23} {}",
208+
bold("GET").green(),
209+
style(path).green(),
210+
style(format!("server-side - {cache_desc}")).dim().italic(),
211+
)
212+
}).collect();
213+
let selection = select_prompt()
214+
.with_prompt("make a request")
215+
.items(&styled)
216+
.interact()
217+
.unwrap();
218+
let path = path_to_cache_desc[selection].0;
219+
Req::get(path).body(()).unwrap()
220+
}
221+
}
222+
223+
mod server {
224+
use super::{CURRENT_TIME, START, Resp, Req, bold, current_duration};
225+
226+
use dialoguer::console::style;
227+
use http::{header, Response, HeaderValue};
228+
229+
pub fn get(req: Req) -> Resp {
230+
println!("{}ing a response for {}", bold("GET").green(), style(req.uri()).green());
231+
let elapsed = CURRENT_TIME.lock().unwrap().duration_since(START).unwrap();
232+
match req.uri().path() {
233+
"/current-time" => Response::builder()
234+
.header(header::CACHE_CONTROL, HeaderValue::from_static("no-store"))
235+
.body(format!("current elapsed time {elapsed:?}")),
236+
"/cached-current-time" => Response::builder()
237+
.header(header::CACHE_CONTROL, HeaderValue::from_static("max-age=10"))
238+
.body(format!("cached current elapsed time {elapsed:?}")),
239+
"/friends-online" => {
240+
let randomish_num = (current_duration().as_secs() / 10 + 1) * 1997 % 15;
241+
Response::builder()
242+
.header(header::CACHE_CONTROL, HeaderValue::from_static("private, max-age=30"))
243+
.body(format!("{randomish_num} friends online"))
244+
}
245+
"/user/123/profile-pic" => {
246+
// picture that changes every 5 minutes
247+
let maybe_client_e_tag = req.headers().get(header::IF_NONE_MATCH);
248+
let (pic, e_tag) = match current_duration().as_secs() / 300 % 3 {
249+
0 => ("(cat looking at stars.jpg)", "1234-abcd"),
250+
1 => ("(mountainside.png)", "aaaa-ffff"),
251+
2 => ("(beach sunset.jpeg)", "9c31-be74"),
252+
_ => unreachable!(),
253+
};
254+
if maybe_client_e_tag.is_some_and(|client_e_tag| client_e_tag == e_tag) {
255+
// handle ETag revalidation
256+
Response::builder()
257+
.header(header::ETAG, HeaderValue::from_str(e_tag).unwrap())
258+
.status(http::StatusCode::NOT_MODIFIED)
259+
.body("".into())
260+
} else {
261+
// no valid ETag. send the full response
262+
Response::builder()
263+
.header(header::CACHE_CONTROL, "max-age=30")
264+
.header(header::ETAG, HeaderValue::from_str(e_tag).unwrap())
265+
.body(pic.to_owned())
266+
}
267+
}
268+
"/cache-busted-123B-8E2A" => Response::builder()
269+
.header(header::CACHE_CONTROL, HeaderValue::from_static("immutable"))
270+
.body("(pretend like this is some very large asset ~('-')~)".to_owned()),
271+
_ => unreachable!(),
272+
}
273+
.unwrap()
274+
}
275+
}

0 commit comments

Comments
 (0)