transactions: Add JSON response format

Rocket makes it easy to dispatch requests for the same path to different
methods based on the `Accept` request header via the `format` argument
to the request attribute.

We'll use this JSON response format to populate a dropdown on the Add
Receipt form, which can be used to automatically fill the fields for
receipt data based on an existing transaction.
bugfix/ci-buildah
Dustin 2025-03-11 07:49:34 -05:00
parent c3c866fb8f
commit 1d5bdfe920
1 changed files with 58 additions and 4 deletions

View File

@ -2,11 +2,13 @@ mod config;
mod firefly; mod firefly;
mod receipts; mod receipts;
use chrono::{DateTime, FixedOffset};
use rocket::fairing::{self, AdHoc}; use rocket::fairing::{self, AdHoc};
use rocket::form::Form; use rocket::form::Form;
use rocket::fs::{FileServer, TempFile}; use rocket::fs::{FileServer, TempFile};
use rocket::http::Status; use rocket::http::Status;
use rocket::response::Redirect; use rocket::response::{Redirect, Responder};
use rocket::serde::json::Json;
use rocket::tokio::io::{AsyncReadExt, BufReader}; use rocket::tokio::io::{AsyncReadExt, BufReader};
use rocket::{Rocket, State}; use rocket::{Rocket, State};
use rocket_db_pools::Database as RocketDatabase; use rocket_db_pools::Database as RocketDatabase;
@ -49,7 +51,7 @@ pub struct Transaction {
pub id: String, pub id: String,
pub amount: f64, pub amount: f64,
pub description: String, pub description: String,
pub date: String, pub date: DateTime<FixedOffset>,
} }
#[derive(rocket::FromForm)] #[derive(rocket::FromForm)]
@ -59,6 +61,27 @@ struct TransactionPostData<'r> {
photo: Vec<TempFile<'r>>, photo: Vec<TempFile<'r>>,
} }
#[derive(Serialize)]
struct ErrorBody {
error: String,
}
#[derive(Responder)]
#[response(status = 500)]
struct ErrorResponse {
error: Json<ErrorBody>,
}
impl ErrorResponse {
pub fn new<S: Into<String>>(error: S) -> Self {
Self {
error: Json(ErrorBody {
error: error.into(),
}),
}
}
}
impl TryFrom<TransactionRead> for Transaction { impl TryFrom<TransactionRead> for Transaction {
type Error = &'static str; type Error = &'static str;
@ -81,7 +104,7 @@ impl TryFrom<TransactionRead> for Transaction {
id: t.id, id: t.id,
amount, amount,
description, description,
date: date.format("%A, %_d %B %Y").to_string(), date,
}) })
} }
} }
@ -91,7 +114,7 @@ async fn index() -> Redirect {
Redirect::to("/receipts") Redirect::to("/receipts")
} }
#[rocket::get("/transactions")] #[rocket::get("/transactions", format = "html", rank = 2)]
async fn transaction_list(ctx: &State<Context>) -> (Status, Template) { async fn transaction_list(ctx: &State<Context>) -> (Status, Template) {
let result = ctx let result = ctx
.firefly .firefly
@ -136,6 +159,36 @@ async fn transaction_list(ctx: &State<Context>) -> (Status, Template) {
} }
} }
#[rocket::get("/transactions", format = "json")]
async fn transaction_list_json(
ctx: &State<Context>,
) -> Result<Json<Vec<Transaction>>, ErrorResponse> {
let result = ctx
.firefly
.search_transactions(&ctx.config.firefly.search_query)
.await
.map_err(|e| {
error!("Error fetching transaction list: {}", e);
ErrorResponse::new(
"Failed to fetch transaction list from Firefly III",
)
})?;
Ok(Json(
result
.data
.into_iter()
.filter_map(|t| match Transaction::try_from(t) {
Ok(t) => Some(t),
Err(e) => {
error!("Error parsing transaction details: {}", e);
None
},
})
.collect(),
))
}
#[rocket::get("/transactions/<id>")] #[rocket::get("/transactions/<id>")]
async fn get_transaction(id: &str, ctx: &State<Context>) -> Option<Template> { async fn get_transaction(id: &str, ctx: &State<Context>) -> Option<Template> {
match ctx.firefly.get_transaction(id).await { match ctx.firefly.get_transaction(id).await {
@ -272,6 +325,7 @@ async fn rocket() -> _ {
rocket::routes![ rocket::routes![
index, index,
transaction_list, transaction_list,
transaction_list_json,
get_transaction, get_transaction,
update_transaction update_transaction
], ],