diff options
| -rw-r--r-- | README.md | 249 |
1 files changed, 192 insertions, 57 deletions
| @@ -34,48 +34,60 @@ mod note; | |||
| 34 | 34 | ||
| 35 | <<note_request_struct>> | 35 | <<note_request_struct>> |
| 36 | 36 | ||
| 37 | <<req_get_notes>> | 37 | <<note_request_impl>> |
| 38 | 38 | ||
| 39 | <<req_get_tags>> | 39 | <<req_get_notes>> |
| 40 | 40 | ||
| 41 | <<req_post_notes>> | 41 | <<req_post_notes>> |
| 42 | ``` | 42 | ``` |
| 43 | 43 | ||
| 44 | ## Anatomy of a note | 44 | ## Anatomy of a note |
| 45 | 45 | ||
| 46 | A *note* is a simple structure recording a unique UUID and a timestamp, both assigned at creation, a list of tags, and the body of the note. | 46 | A *note* is a simple structure recording a unique ID and a timestamp, both assigned at creation, a list of tags, and the body of the note. |
| 47 | A set of notes is just a `Vec` of `Note`s. | ||
| 48 | 47 | ||
| 49 | ```{#note_struct .rust} | 48 | ```{#note_struct .rust} |
| 50 | #[derive(Debug, Deserialize, Serialize)] | 49 | #[derive(Debug, Deserialize, Serialize)] |
| 51 | pub struct Note { | 50 | pub struct Note { |
| 52 | uuid: Uuid, | 51 | id: i64, |
| 53 | timestamp: DateTime<Utc>, | 52 | timestamp: DateTime<Utc>, |
| 54 | tags: Vec<String>, | 53 | tags: Vec<String>, |
| 55 | body: String, | 54 | body: String, |
| 56 | } | 55 | } |
| 57 | |||
| 58 | type Notes = Vec<Note>; | ||
| 59 | ``` | 56 | ``` |
| 60 | 57 | ||
| 61 | A `Note` can be automatically created from a `NoteRequest`. | ||
| 62 | |||
| 63 | ```{#note_impl .rust} | 58 | ```{#note_impl .rust} |
| 64 | impl Note { | 59 | impl Note { |
| 65 | fn new(body: String, tags: Vec<String>) -> Self { | 60 | pub fn new(id: i64, timestamp: DateTime<Utc>, tags: Vec<String>, body: String) -> Self { |
| 66 | Self { uuid: Uuid::new_v4(), timestamp: Utc::now(), tags, body } | 61 | Self { id, timestamp, tags, body } |
| 67 | } | 62 | } |
| 68 | } | 63 | } |
| 69 | 64 | ||
| 70 | impl From<NoteRequest> for Note { | 65 | ``` |
| 71 | fn from(req: NoteRequest) -> Self { | 66 | |
| 72 | Self::new(req.body, req.tags) | 67 | A `Note` can also be create from a `Row`, result of a query over the database. |
| 73 | } | 68 | |
| 69 | ```{#note_uses .rust} | ||
| 70 | use rusqlite::Row; | ||
| 71 | ``` | ||
| 72 | |||
| 73 | ```{#note_impl .rust} | ||
| 74 | impl From<&Row<'_>> for Note { | ||
| 75 | fn from(row: &Row<'_>) -> Note { | ||
| 76 | Note::new( | ||
| 77 | row.get(0).expect("Failed to read column 1"), // id | ||
| 78 | row.get(1).expect("Failed to read column 2"), // timestamp | ||
| 79 | serde_json::from_value(row.get::<_,serde_json::Value>(2).expect("Failed to read column 3")).expect("Failed to parse JSON"), // tags | ||
| 80 | row.get(3).expect("Failed to read column 4")) // body | ||
| 81 | } | ||
| 74 | } | 82 | } |
| 75 | ``` | 83 | ``` |
| 76 | 84 | ||
| 77 | Similarly, a `NoteRequest` is what a client would request at note creation. | 85 | Similarly, a `NoteRequest` is what a client would request at note creation. |
| 78 | It contains the same information as a `Note` without the information assigned at creation by the server. | 86 | It contains the same information as a `Note` without the information assigned at creation by the server and the database. |
| 87 | |||
| 88 | ```{#note_uses .rust} | ||
| 89 | use serde_json::Value; | ||
| 90 | ``` | ||
| 79 | 91 | ||
| 80 | ```{#note_request_struct .rust} | 92 | ```{#note_request_struct .rust} |
| 81 | #[derive(Debug, Deserialize, Serialize)] | 93 | #[derive(Debug, Deserialize, Serialize)] |
| @@ -83,8 +95,18 @@ pub struct NoteRequest { | |||
| 83 | tags: Vec<String>, | 95 | tags: Vec<String>, |
| 84 | body: String, | 96 | body: String, |
| 85 | } | 97 | } |
| 98 | ``` | ||
| 99 | |||
| 100 | The stucture can also be converted into a tuple of parameters to pass a SQL query (see [`Params`](https://docs.rs/rusqlite/latest/rusqlite/trait.Params.html) from `rusqlite`). | ||
| 86 | 101 | ||
| 87 | type NoteRequests = Vec<NoteRequest>; | 102 | ```{#note_request_impl .rust} |
| 103 | pub type NoteParams = (DateTime<Utc>, Value, String); | ||
| 104 | |||
| 105 | impl From<NoteRequest> for NoteParams { | ||
| 106 | fn from(req: NoteRequest) -> NoteParams { | ||
| 107 | (Utc::now(), Value::from(req.tags), req.body) | ||
| 108 | } | ||
| 109 | } | ||
| 88 | ``` | 110 | ``` |
| 89 | 111 | ||
| 90 | ### (De)serialization | 112 | ### (De)serialization |
| @@ -99,18 +121,9 @@ serde = { version = "1.0", features = ["derive"] } | |||
| 99 | use serde::{Serialize, Deserialize}; | 121 | use serde::{Serialize, Deserialize}; |
| 100 | ``` | 122 | ``` |
| 101 | 123 | ||
| 102 | ### UUIDs | 124 | ### IDs |
| 103 | 125 | ||
| 104 | UUIDs are unique 128-bit identifiers, stored as 16 octets, and formatted as a hex string in five groups, e.g., `67e55044-10b1-426f-9247-bb680e5fe0c8`. | 126 | We are using a `i64` as unique identifier because the field is automatically handled by SQLite, and `i64` is the most convenient type to convert from/to SQLite's `INTEGER` (according to [`rusqlite`](https://docs.rs/rusqlite/latest/rusqlite/types/index.html#)). |
| 105 | Have a look at the [Wikipedia entry](http://en.wikipedia.org/wiki/Universally_unique_identifier) for more information. | ||
| 106 | |||
| 107 | ```{#dependencies .toml} | ||
| 108 | uuid = { version = "1.1", features = ["v4","fast-rng","serde"] } | ||
| 109 | ``` | ||
| 110 | |||
| 111 | ```{#note_uses .rust} | ||
| 112 | use uuid::Uuid; | ||
| 113 | ``` | ||
| 114 | 127 | ||
| 115 | ### Timestamps | 128 | ### Timestamps |
| 116 | 129 | ||
| @@ -135,10 +148,21 @@ actix-web = "4.1" | |||
| 135 | ```{#note_uses .rust} | 148 | ```{#note_uses .rust} |
| 136 | use actix_web::{HttpResponse,Responder,web,get,post}; | 149 | use actix_web::{HttpResponse,Responder,web,get,post}; |
| 137 | use actix_web::http::header::ContentType; | 150 | use actix_web::http::header::ContentType; |
| 151 | use super::db::Pool; | ||
| 138 | ``` | 152 | ``` |
| 139 | 153 | ||
| 140 | Each request handlers is an *async* function that accepts zero or more parameters, extracted from a request (see [`FromRequest`](https://docs.rs/actix-web/latest/actix_web/trait.FromRequest.html) trait), and returns an [`HttpResponse`](https://docs.rs/actix-web/latest/actix_web/struct.HttpResponse.html). | 154 | Each request handlers is an *async* function that accepts zero or more parameters, extracted from a request (see [`FromRequest`](https://docs.rs/actix-web/latest/actix_web/trait.FromRequest.html) trait), and returns an [`HttpResponse`](https://docs.rs/actix-web/latest/actix_web/struct.HttpResponse.html). |
| 141 | 155 | ||
| 156 | Internally requests will be carried out by querying the underlying SQLite database. | ||
| 157 | |||
| 158 | ```{#main_mods .rust} | ||
| 159 | mod db; | ||
| 160 | ``` | ||
| 161 | |||
| 162 | ```{#note_uses .rust} | ||
| 163 | use super::db; | ||
| 164 | ``` | ||
| 165 | |||
| 142 | ## Resources | 166 | ## Resources |
| 143 | 167 | ||
| 144 | - [Tutorial](https://web.archive.org/web/20220710213947/https://hub.qovery.com/guides/tutorial/create-a-blazingly-fast-api-in-rust-part-1/) | 168 | - [Tutorial](https://web.archive.org/web/20220710213947/https://hub.qovery.com/guides/tutorial/create-a-blazingly-fast-api-in-rust-part-1/) |
| @@ -151,11 +175,12 @@ The function takes 0 parameters and returns a JSON object. | |||
| 151 | 175 | ||
| 152 | ```{#req_get_notes .rust} | 176 | ```{#req_get_notes .rust} |
| 153 | #[get("/notes")] | 177 | #[get("/notes")] |
| 154 | pub async fn list() -> HttpResponse { | 178 | pub async fn get_notes(pool: web::Data<Pool>) -> HttpResponse { |
| 155 | let notes: Notes; | 179 | let mut conn = web::block(move || pool.get()) |
| 156 | 180 | .await | |
| 157 | <<notes_retrieve>> | 181 | .expect("Blocking error") |
| 158 | 182 | .expect("Error getting from connection pool"); | |
| 183 | let notes: Vec<Note> = db::get_notes(&mut conn); | ||
| 159 | HttpResponse::Ok() | 184 | HttpResponse::Ok() |
| 160 | .content_type(ContentType::json()) | 185 | .content_type(ContentType::json()) |
| 161 | .json(notes) | 186 | .json(notes) |
| @@ -175,18 +200,13 @@ New notes can be added by POSTing a JSON array of `NoteRequest`s of the form | |||
| 175 | 200 | ||
| 176 | ```{#req_post_notes .rust} | 201 | ```{#req_post_notes .rust} |
| 177 | #[post("/notes")] | 202 | #[post("/notes")] |
| 178 | pub async fn add(new_notes: web::Json<NoteRequests>) -> impl Responder { | 203 | pub async fn post_notes(pool: web::Data<Pool>, req: web::Json<Vec<NoteRequest>>) -> impl Responder { |
| 179 | let mut new_notes: Notes = | 204 | let mut conn = web::block(move || pool.get()) |
| 180 | new_notes | 205 | .await |
| 181 | .into_inner() | 206 | .expect("Blocking error") |
| 182 | .into_iter() | 207 | .expect("Error getting from connection pool"); |
| 183 | .map(|n| n.into()) | 208 | let res = db::post_notes(&mut conn, req.into_inner()); |
| 184 | .collect(); | 209 | format!("Successfully added {res} note(s)") |
| 185 | let count = new_notes.len(); | ||
| 186 | |||
| 187 | <<notes_add>> | ||
| 188 | |||
| 189 | format!("Successfully added {} note(s)", count) | ||
| 190 | } | 210 | } |
| 191 | ``` | 211 | ``` |
| 192 | 212 | ||
| @@ -200,7 +220,7 @@ One or more tags separated by `+` can be passed to the request. | |||
| 200 | pub async fn get_tags(tags: web::Path<String>) -> HttpResponse { | 220 | pub async fn get_tags(tags: web::Path<String>) -> HttpResponse { |
| 201 | let tags = tags.split('+').map(|t| t.to_string()).collect::<Vec<_>>(); | 221 | let tags = tags.split('+').map(|t| t.to_string()).collect::<Vec<_>>(); |
| 202 | 222 | ||
| 203 | let notes: Notes; | 223 | let notes: Vec<Note>; |
| 204 | <<notes_retrieve>> | 224 | <<notes_retrieve>> |
| 205 | let tagged = notes.into_iter().filter(|n| tags.iter().all(|t| n.tags.contains(t))).collect::<Vec<_>>(); | 225 | let tagged = notes.into_iter().filter(|n| tags.iter().all(|t| n.tags.contains(t))).collect::<Vec<_>>(); |
| 206 | 226 | ||
| @@ -227,7 +247,7 @@ The main program is structured as follows | |||
| 227 | The main service will instantiate a new `App` running within a `HttpServer` bound to *localhost* on port 8080. | 247 | The main service will instantiate a new `App` running within a `HttpServer` bound to *localhost* on port 8080. |
| 228 | 248 | ||
| 229 | ```{#main_uses .rust} | 249 | ```{#main_uses .rust} |
| 230 | use actix_web::{App, HttpServer}; | 250 | use actix_web::{App, HttpServer, web}; |
| 231 | ``` | 251 | ``` |
| 232 | 252 | ||
| 233 | The `App` will register all request handlers defined above. | 253 | The `App` will register all request handlers defined above. |
| @@ -235,11 +255,12 @@ The `App` will register all request handlers defined above. | |||
| 235 | ```{#main_service .rust} | 255 | ```{#main_service .rust} |
| 236 | #[actix_web::main] | 256 | #[actix_web::main] |
| 237 | async fn main() -> std::io::Result<()> { | 257 | async fn main() -> std::io::Result<()> { |
| 238 | HttpServer::new(|| { | 258 | let db_pool = db::get_connection_pool("notes.db"); |
| 259 | HttpServer::new(move || { | ||
| 239 | App::new() | 260 | App::new() |
| 240 | .service(note::list) | 261 | .app_data(web::Data::new(db_pool.clone())) |
| 241 | .service(note::add) | 262 | .service(note::get_notes) |
| 242 | .service(note::get_tags) | 263 | .service(note::post_notes) |
| 243 | }) | 264 | }) |
| 244 | .bind(("127.0.0.1", 8080))? | 265 | .bind(("127.0.0.1", 8080))? |
| 245 | .run() | 266 | .run() |
| @@ -247,6 +268,119 @@ async fn main() -> std::io::Result<()> { | |||
| 247 | } | 268 | } |
| 248 | ``` | 269 | ``` |
| 249 | 270 | ||
| 271 | # SQLite backend | ||
| 272 | |||
| 273 | `Note`s are saved into a [SQLite](https://sqlite.org/) database. | ||
| 274 | The `notes` database contains a single table mirroring the `Note`'s structure. | ||
| 275 | |||
| 276 | ```{#notes.sql .sql} | ||
| 277 | CREATE TABLE notes ( | ||
| 278 | id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| 279 | timestamp TEXT NOT NULL, | ||
| 280 | tags TEXT NOT NULL, | ||
| 281 | body TEXT NOT NULL | ||
| 282 | ); | ||
| 283 | ``` | ||
| 284 | |||
| 285 | Note that, apart from [standard SQLite types](https://docs.rs/rusqlite/latest/rusqlite/types/index.html#), `DateTime<Utc>` is converted to/from `TEXT`, `Vec<String>` is first wrapped in a `Value` (from the `serde_json` crate) and then converted from/to `TEXT`. | ||
| 286 | |||
| 287 | `id`s are handled automatically by SQLite and are not set on the Rust side. | ||
| 288 | |||
| 289 | A new database can be bootstraped in the current directory as follows: | ||
| 290 | |||
| 291 | ```sh | ||
| 292 | sqlite3 ./notes.db < notes.sql | ||
| 293 | ``` | ||
| 294 | |||
| 295 | ## Interfacing with SQLite from Rust | ||
| 296 | |||
| 297 | To interface with the underlying database we use [`rusqlite`](https://github.com/rusqlite/rusqlite) along with [`r2d2`](https://github.com/sfackler/r2d2) to create a connection pool. | ||
| 298 | |||
| 299 | ```{#dependencies .toml} | ||
| 300 | r2d2 = "0.8" | ||
| 301 | r2d2_sqlite = "0.21" | ||
| 302 | rusqlite = { version = "0.28", features = ["chrono","serde_json"] } | ||
| 303 | ``` | ||
| 304 | |||
| 305 | The following code sets up a connection pool to the SQLite database. | ||
| 306 | |||
| 307 | ```{#db_uses .rust} | ||
| 308 | use r2d2_sqlite::SqliteConnectionManager; | ||
| 309 | ``` | ||
| 310 | |||
| 311 | ```{#db_types .rust} | ||
| 312 | pub type Pool = r2d2::Pool<SqliteConnectionManager>; | ||
| 313 | ``` | ||
| 314 | |||
| 315 | ```{#db_connection_pool .rust} | ||
| 316 | pub fn get_connection_pool(db: &str) -> Pool { | ||
| 317 | let manager = SqliteConnectionManager::file(db); | ||
| 318 | r2d2::Pool::new(manager).expect("Unable to connect to 'notes.db'") | ||
| 319 | } | ||
| 320 | ``` | ||
| 321 | |||
| 322 | For the sake of convenience, all operations on the database are stored on a separate file. | ||
| 323 | |||
| 324 | ```{#db.rs .rust path="src/"} | ||
| 325 | <<db_uses>> | ||
| 326 | |||
| 327 | <<db_types>> | ||
| 328 | |||
| 329 | <<db_connection_pool>> | ||
| 330 | |||
| 331 | <<db_operations>> | ||
| 332 | ``` | ||
| 333 | |||
| 334 | ### Retrieving notes | ||
| 335 | |||
| 336 | We first build the query (caching it for later) and then execute it to retrieve all requested notes. | ||
| 337 | |||
| 338 | We return a `Vec<Note>` built from the query result. | ||
| 339 | |||
| 340 | ```{#db_uses .rust} | ||
| 341 | use rusqlite::Connection; | ||
| 342 | use super::note::Note; | ||
| 343 | ``` | ||
| 344 | |||
| 345 | ```{#db_operations .rust} | ||
| 346 | pub fn get_notes(conn: &mut Connection) -> Vec<Note> { | ||
| 347 | let mut query = conn.prepare_cached("SELECT * FROM notes") | ||
| 348 | .expect("Failed to prepare SELECT query"); | ||
| 349 | query | ||
| 350 | .query_map([], |row| Ok(Note::from(row))) | ||
| 351 | .and_then(Iterator::collect) | ||
| 352 | .expect("Failed to collect query results") | ||
| 353 | } | ||
| 354 | |||
| 355 | ``` | ||
| 356 | |||
| 357 | ### Creating notes | ||
| 358 | |||
| 359 | When inserting new `Note`s in the database, we loop over the requested notes, attaching a timestamp and executing an `INSERT` SQL query. | ||
| 360 | |||
| 361 | SQLite will take care of attaching an ID to the new entry. | ||
| 362 | |||
| 363 | Operations are executed into a single transaction [to achieve better performances](https://github.com/rusqlite/rusqlite/issues/262#issuecomment-294895051). | ||
| 364 | |||
| 365 | ```{#db_uses .rust} | ||
| 366 | use super::note::{NoteParams,NoteRequest}; | ||
| 367 | ``` | ||
| 368 | |||
| 369 | ```{#db_operations .rust} | ||
| 370 | pub fn post_notes(conn: &mut Connection, reqs: Vec<NoteRequest>) -> usize { | ||
| 371 | let tx = conn.transaction().expect("Failed to start transaction"); | ||
| 372 | { | ||
| 373 | let mut stmt = tx.prepare_cached( | ||
| 374 | "INSERT INTO notes (timestamp, tags, body) VALUES (?, ?, ?)" | ||
| 375 | ).expect("Failed to prepare INSERT query"); | ||
| 376 | reqs.into_iter().for_each(|req| { stmt.execute::<NoteParams>(req.into()).expect("Failed to execute INSERT query"); }); | ||
| 377 | } | ||
| 378 | tx.commit().expect("Commit failed"); | ||
| 379 | 0 | ||
| 380 | } | ||
| 381 | |||
| 382 | ``` | ||
| 383 | |||
| 250 | # Testing | 384 | # Testing |
| 251 | 385 | ||
| 252 | ## Using a file as a backend | 386 | ## Using a file as a backend |
| @@ -257,10 +391,6 @@ This is a temporary solution until an interface to a database (most likely SQLit | |||
| 257 | serde_json = "1.0" | 391 | serde_json = "1.0" |
| 258 | ``` | 392 | ``` |
| 259 | 393 | ||
| 260 | ```{#note_uses .rust} | ||
| 261 | use std::fs::File; | ||
| 262 | ``` | ||
| 263 | |||
| 264 | ### Retrieving notes | 394 | ### Retrieving notes |
| 265 | 395 | ||
| 266 | ```{#notes_retrieve .rust} | 396 | ```{#notes_retrieve .rust} |
| @@ -273,7 +403,7 @@ notes = { | |||
| 273 | ### Adding notes | 403 | ### Adding notes |
| 274 | 404 | ||
| 275 | ```{#notes_add .rust} | 405 | ```{#notes_add .rust} |
| 276 | let mut notes: Notes; | 406 | let mut notes: Vec<Note>; |
| 277 | <<notes_retrieve>> | 407 | <<notes_retrieve>> |
| 278 | notes.append(&mut new_notes); | 408 | notes.append(&mut new_notes); |
| 279 | 409 | ||
| @@ -281,11 +411,16 @@ let db = File::create("notes.db").expect("Unable to create/open 'notes.db'"); | |||
| 281 | serde_json::to_writer(&db,¬es).expect("Unable to write to 'notes.db'"); | 411 | serde_json::to_writer(&db,¬es).expect("Unable to write to 'notes.db'"); |
| 282 | ``` | 412 | ``` |
| 283 | 413 | ||
| 284 | # Open questions | 414 | # TODOs |
| 415 | |||
| 416 | - Better error handling | ||
| 417 | |||
| 418 | ## Open questions | ||
| 285 | 419 | ||
| 286 | - Should one be able to delete notes? Or mark them as read/processed? | 420 | - Should one be able to delete notes? Or mark them as read/processed? |
| 287 | - Authentication method? | 421 | - Authentication method? |
| 288 | - Custom filters on retrieval. | 422 | - Custom filters on retrieval. |
| 423 | - `rusqlite` ships with SQLite bundled if necessary (useful to bootstrap the db?) | ||
| 289 | 424 | ||
| 290 | # Credits | 425 | # Credits |
| 291 | 426 | ||
