Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Basic Backlog implementation #2

Merged
merged 16 commits into from
Mar 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"rust-analyzer.check.command": "clippy"
}
14 changes: 12 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@ name = "khares"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
# Required FF for json support
rocket = { version = "0.5.0", features = ["json"] }

# Avoids extra annotation over serializable structs
serde = { version = "1.0", features = ["derive"] }

# Datastore (right now)
mongodb = "2.8.1"

# Raw prints are the enemy of the people
log = "0.4.21"
env_logger = "0.8.4"
36 changes: 33 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,35 @@
This is a fun toy project for a personal home dashboard meant to both be useful and as some Rust practice. Planned features:
This is a fun toy project for a personal home dashboard server meant to both be useful and as some Rust practice. Planned features:
1. Backlog tracking for games/movies/tv shows/books and a "now playing/reading/etc" (fancy spreadsheet)
2. Automatic planning of run/cycling routes based on a training schedule each morning
3. Getting the Nasa Astronomy Pictre of the Day/current xckd/wikipedia home page/etc or any other fun stuff
4. Plugin system for any other pieces that someone may want to use!
3. Getting the Nasa Astronomy Picture of the Day/current xckd/wikipedia home page/etc or any other fun stuff

# Currently working on
- Backlog system v0.1.0 (I am the one millionth person to write a new thing that's basically a discount spreadsheet)

## Development
You'll need a mongodb instance running locally for the backlog system, the easiest way to do this is with docker:

```sh
$ docker run --name khares-mongodb -p 27017:27017 -d mongodb/mongodb-community-server:latest
```

^ by default the above will not persist data once the container is gone, if you need to volume mount it:

```sh
$ docker run --name khares-mongodb -v /path/on/local:/data/db -p 27017:27017 -d mongodb/mongodb-community-server:latest
```

## Quick CURL command reference
```sh
# post a new entry:
curl --header "Content-Type: application/json" \
--request POST \
--data '{"title":"Hades","progress":"Complete","category":"Game"}' \
http://localhost:8000/backlog/item

# get all entries
curl http://localhost:8000/backlog

# Delete an entry by title/genre
curl --request DELETE "http://localhost:8000/backlog/item?title=Hades&category=Game"
```
67 changes: 67 additions & 0 deletions src/items.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
use std::fmt::Display;

use rocket::serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize, Debug)]
pub struct BacklogItem {
category: BacklogItemCategory,
title: String,
progress: BacklogItemProgress,
favorite: Option<bool>,
replay: Option<bool>,
notes: Option<String>,
// TODO: use custom type to allow for user-defined rating scales
rating: Option<i32>,
//TODO: use custom type to allow user-defined genres & easier filtering
genre: Option<String>,
// TODO: Enable & this should be refactored to an array of tag types eventually
// tags: Option<Vec<String>>
}

impl Display for BacklogItem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {} {}", self.title, self.category, self.progress)
}
}

#[derive(Serialize, Deserialize, Debug)]
pub enum BacklogItemCategory {
Game,
Book,
Movie,
Show,
}

impl Display for BacklogItemCategory {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// There's probably a better way to do this with a macro
let as_string = match self {
Self::Game => "Game",
Self::Book => "Book",
Self::Movie => "Movie",
Self::Show => "Show",
};
write!(f, "Type: {}", as_string)
}
}

#[derive(Serialize, Deserialize, Debug)]
pub enum BacklogItemProgress {
Backlog,
InProgress,
Complete,
DNF,
}

impl Display for BacklogItemProgress {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// There's probably a better way to do this with a macro
let as_string = match self {
Self::Backlog => "Baclog",
Self::InProgress => "In Progress",
Self::Complete => "Complete",
Self::DNF => "DNF",
};
write!(f, "Progress: {}", as_string)
}
}
112 changes: 110 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,111 @@
fn main() {
println!("Hello, world!");
// todo: clean up this import vomit
#[macro_use]
extern crate rocket;
use items::BacklogItem;
use log::{error, info};
use mongodb::{
bson::doc,
options::ClientOptions,
Client,
};
use rocket::serde::json::{json, Json, Value};
use rocket::State;
use storage::{BacklogStore, MongoBacklogStore};

pub mod items;
pub mod storage;

#[get("/ping")]
fn ping() -> &'static str {
"pong"
}

// TODO: allow user param to be passed for queries to dispatch to correct mongo db
// let db = self.client.database(&self.user);

// TODO: move these to their own file/handler crate

// TODO: pagination
#[get("/?<sort_by>&<filter_field>&<filter_value>")]
async fn list_backlog_entries(
sort_by: Option<&str>,
filter_field: Option<&str>,
filter_value: Option<&str>,
db: &State<MongoBacklogStore>,
) -> Json<Vec<items::BacklogItem>> {
// if we're given both parts of a filter, use it. Otherwise pass None.
let document_matcher = match (filter_field, filter_value) {
(Some(field), Some(val)) => doc! { field: val }.into(),
_ => None,
};

let all_entries = db.get_items(document_matcher, sort_by).await;
match all_entries {
Ok(entries) => Json(entries),
Err(err) => {
// TODO: convert this to a failed response code instead of just silently swallowing
error!("Error occured getting entries: {:?}", err);
Json(Vec::new())
}
}
}

#[post("/item", data = "<new_item>")]
async fn create_backlog_entry(new_item: Json<BacklogItem>, db: &State<MongoBacklogStore>) -> Value {
if db.write_items(vec![new_item.into_inner()]).await {
json!({ "status": "success" })
} else {
json!({ "status": "fail"})
}
}

// delete by title and category, which should be a sufficiently unique combination
// both are required fields for a BacklogItem so all entries should have them
#[delete("/item?<title>&<category>")]
async fn remove_backlog_entry(title: &str, category: &str, db: &State<MongoBacklogStore>) -> Value {
// NOTE: this is case-sensitive (intentionally)
if db.delete_items(doc! { "title": title, "category": category }).await {
json!({ "status": "success" })
} else {
json!({ "status": "fail"})
}
}

#[rocket::main]
async fn main() -> Result<(), rocket::Error> {
env_logger::init();

info!("Starting server...");
// Parse a connection string into an options struct.
// we explicitly want to panic here if we can't connect to the database, so use `.unwrap()`
let client_options = ClientOptions::parse("mongodb://localhost:27017")
.await
.unwrap();
let mongo_client = Client::with_options(client_options).unwrap();

info!("Successfully connected to mongo instance...");

// Mount paths by namespace
rocket::build()
.mount("/util", routes![ping])
.mount(
"/backlog",
routes![
list_backlog_entries,
create_backlog_entry,
remove_backlog_entry
],
)
.manage(
// TODO: de-hardcode this
// This is global application state accessible by any handler
// through the magic of mongo, these will be created automatically if they don't already exist
MongoBacklogStore {
user_collection: mongo_client.database("user1").collection("Backlog"),
},
)
.launch()
.await?;

Ok(())
}
81 changes: 81 additions & 0 deletions src/storage.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
use crate::items::BacklogItem;
use log::{debug, error};
use mongodb::bson::{doc, Document};
use mongodb::options::{DeleteOptions, FindOptions};
use mongodb::Collection;
use rocket::futures::StreamExt;

// TODO: this needs to be sendable to use generically in rocket state
/// A generic Backlog store trait to allow test mocking.
/// (or swapping the underlying store in the future if I feel like returning to the comfortable land of relational DBs)
pub trait BacklogStore {
fn write_items(&self, new_items: Vec<BacklogItem>) -> impl std::future::Future<Output = bool> + Send;
fn get_items(
&self,
filter: impl Into<Option<Document>>,
sort_by: Option<&str>,
) -> impl std::future::Future<Output = Result<Vec<BacklogItem>, mongodb::error::Error>>;
fn delete_items(&self, filter: Document) -> impl std::future::Future<Output = bool> + Send;
}

// TODO: this needs to be a client instead of a user collection to support multiple users
pub struct MongoBacklogStore {
// each user has their own DB
// each user has (right now) one collection with all their backlog items in it
// I could split this on some field like type but that feels needless for something so simple
pub user_collection: Collection<BacklogItem>,
}

impl BacklogStore for MongoBacklogStore {
async fn write_items(&self, new_items: Vec<BacklogItem>) -> bool {
let res = self.user_collection.insert_many(new_items, None).await;
match res {
Ok(insert_result) => {
debug!("successfully inserted new items: {:?}", insert_result);
true
}
Err(err) => {
error!("Issue inserting new items to backlog: {}", err);
false
}
}
}

async fn get_items(
&self,
filter: impl Into<Option<Document>>,
sort_by: Option<&str>,
) -> Result<Vec<BacklogItem>, mongodb::error::Error> {
// if a sort field is provided, use that. Otherwise deafault to category.
let sort_option = sort_by.unwrap_or("category");
let find_options = FindOptions::builder().sort(doc! { sort_option: 1 }).build();
// TODO: allow pagination options to be passed here as part of findoptions
let mut cursor = self.user_collection.find(filter, find_options).await?;
let mut items = Vec::<BacklogItem>::new();
while let Some(item) = cursor.next().await {
match item {
Ok(backlog_entry) => items.push(backlog_entry),
Err(err) => {
error!("Unknown item in iteration: {}", err)
}
}
}
debug!("Items returned from mongodb: {:?}", items);
Ok(items)
}

async fn delete_items(&self, filter: Document) -> bool {
let opts: DeleteOptions = DeleteOptions::builder().build();
let res = self.user_collection.delete_many(filter, opts).await;
match res {
Ok(delete_results) => {
debug!("successfully deleted items: {:?}", delete_results);
true
}
Err(err) => {
error!("Issue deleting items from backlog: {}", err);
false
}
}
}
}
Loading