Skip to main content

application_quote_book/
application_quote_book.rs

1//! A delightfully 90s website to store and view quotes
2//! This example is slightly more advanced than the pastebin one because it uses a file to save the quotes.
3//! In a real project you can probably use dependencies like `rusqlite` for a proper database.
4//! But hey, more examples cant hurt!
5
6use std::{
7    collections::HashMap,
8    fs,
9    net::Ipv4Addr,
10    path::PathBuf,
11    sync::RwLock,
12    time::{SystemTime, UNIX_EPOCH},
13};
14
15use afire::{
16    extension::date::imp_date,
17    internal::encoding::url,
18    trace,
19    trace::{set_log_level, Level},
20    Content, HeaderType, Method, Query, Response, Server, Status,
21};
22
23struct App {
24    path: PathBuf,
25    quotes: RwLock<HashMap<String, Quote>>,
26}
27
28struct Quote {
29    name: String,
30    value: String,
31    date: u64,
32}
33
34fn main() {
35    set_log_level(Level::Trace);
36    let app = App::new(PathBuf::from("quotes.txt"));
37    app.load();
38
39    let mut server = Server::new(Ipv4Addr::LOCALHOST, 8080).state(app);
40
41    // Route to serve the homepage (page that has add quote form)
42    server.route(Method::GET, "/", |_| {
43        Response::new()
44            .text(String::new() + HEADER + HOME)
45            .content(Content::HTML)
46    });
47
48    // Route to handle creating new quotes.
49    // After successful creation the user will be redirected to the new quotes page.
50    server.stateful_route(Method::POST, "/api/new", |app, req| {
51        let form = Query::from_body(&String::from_utf8_lossy(&req.body));
52        let name =
53            url::decode(form.get("author").expect("No author supplied")).expect("Invalid author");
54        let body =
55            url::decode(form.get("quote").expect("No quote supplied")).expect("Invalid quote");
56
57        let quote = Quote {
58            name,
59            value: body,
60            date: now(),
61        };
62        let mut quotes = app.quotes.write().unwrap();
63        let id = quotes.len();
64        quotes.insert(id.to_string(), quote);
65        drop(quotes);
66        trace!(Level::Trace, "Added new quote #{id}");
67
68        app.save();
69        Response::new()
70            .status(Status::SeeOther)
71            .header(HeaderType::Location, format!("/quote/{id}"))
72            .text("Redirecting to quote page.")
73    });
74
75    server.stateful_route(Method::GET, "/quote/{id}", |app, req| {
76        let id = req.param("id").unwrap();
77        if id == "undefined" {
78            return Response::new();
79        }
80
81        let id = id.parse::<usize>().expect("ID is not a valid integer");
82        let quotes = app.quotes.read().unwrap();
83        if id >= quotes.len() {
84            return Response::new()
85                .status(Status::NotFound)
86                .text(format!("No quote with the id {id} was found."));
87        }
88
89        let quote = quotes.get(&id.to_string()).unwrap();
90        Response::new().content(Content::HTML).text(
91            String::new()
92                + HEADER
93                + &QUOTE
94                    .replace("{QUOTE}", &quote.value)
95                    .replace("{AUTHOR}", &quote.name)
96                    .replace("{TIME}", &imp_date(quote.date)),
97        )
98    });
99
100    server.stateful_route(Method::GET, "/quotes", |app, _req| {
101        let mut out = String::from(HEADER);
102        out.push_str("<ul>");
103        for i in app.quotes.read().unwrap().iter() {
104            out.push_str(&format!(
105                "<li><a href=\"/quote/{}\">\"{}\" - {}</a></li>\n",
106                i.0, i.1.name, i.1.value
107            ));
108        }
109
110        Response::new().text(out + "</ul>").content(Content::HTML)
111    });
112
113    // Note: In a production application you may want to multithread the server with the Server::start_threaded method.
114    server.start().unwrap();
115}
116
117fn now() -> u64 {
118    SystemTime::now()
119        .duration_since(UNIX_EPOCH)
120        .expect("Time went backwards")
121        .as_secs()
122}
123
124impl App {
125    fn new(path: PathBuf) -> Self {
126        Self {
127            path,
128            quotes: RwLock::new(HashMap::new()),
129        }
130    }
131
132    fn load(&self) {
133        if !self.path.exists() {
134            trace!(Level::Trace, "No save file found. Skipping loading.");
135            return;
136        }
137
138        let data = fs::read_to_string(&self.path).unwrap();
139        let mut quotes = self.quotes.write().unwrap();
140        quotes.clear();
141
142        for i in data.lines() {
143            let (name, quote) = i.split_once(':').unwrap();
144            if let Some(i) = Quote::load(quote) {
145                quotes.insert(name.to_owned(), i);
146                continue;
147            }
148            trace!(Level::Error, "Error loading entry");
149        }
150
151        trace!("Loaded {} entries", quotes.len());
152    }
153
154    fn save(&self) {
155        trace!(Level::Trace, "Saving quotes");
156        let mut out = String::new();
157
158        for i in self.quotes.read().unwrap().iter() {
159            out.push_str(&format!("{}:{}\n", i.0, i.1.save()));
160        }
161
162        fs::write(&self.path, out).unwrap();
163    }
164}
165
166impl Quote {
167    fn save(&self) -> String {
168        format!(
169            "{}:{}:{}",
170            url::encode(&self.name),
171            url::encode(&self.value),
172            self.date
173        )
174    }
175
176    fn load(line: &str) -> Option<Self> {
177        let mut parts = line.split(':');
178        let name = url::decode(parts.next()?).unwrap();
179        let value = url::decode(parts.next()?).unwrap();
180        let date = parts.next()?.parse().ok()?;
181
182        Some(Self { name, value, date })
183    }
184}
185
186// Define webpage sources
187// In all of my real applications, the web data is put in a web/ directory and served with the ServeStatic middleware.
188// Im just embedding it in the code here to keep the example all contained in one file, please don't really do this.
189// If you want to see some examples of some real afire applications checkout the 'afire hub' at https://connorcode.com/writing/afire.
190
191const HEADER: &str = r#"
192<a href="/">New Quote</a> •
193<a href="/quotes">All Quotes</a>
194"#;
195
196// Note: When submitting the form it will send a POST to /api/new
197const HOME: &str = r#"
198<form method="post" action="/api/new">
199    <label for="author">Author:</label>
200    <input type="text" name="author" required>
201    <br>
202    <label for="quote">Quote:</label>
203    <textarea name="quote" id="quote" cols="30" rows="4"></textarea>
204    <br>
205    <input type="submit" value="Submit">
206</form>
207"#;
208
209const QUOTE: &str = r#"
210<p>"{QUOTE}"</p>
211<p> - {AUTHOR} ({TIME})</p>
212"#;