Two of my favorite tools lately are Observable (observablehq.com) (a long time favorite for quickly prototyping javascript and creating interactive visualizations) and bean-count (a more recent addition for doing “hobbyist double entry accounting” (a special form of “fun”)).
fava
is a great open source web UI for bean-count that gives great default reports, and lets you submit custom queries. However, part of the reason bean-count is so cool is that you have this super rich data that I want to experiment rendering in all kinds of ways, and also combine with other data like my predicted future spending etc.
Naturally, I wanted to combine my two favorite tools.
Localhost & CORS
Fava has a nice “query” API endpoint, and I’m usually running Fava, so I was hoping I could just hit their endpoint. The downside is that it returns its data in HTML format. I thought about trying to parse the HTML to get the data out (a very reasonable, fixed amount of time), but decided I’d try to make a little web-server in Rust to serve my queries instead (a maybe less reasonable, more “fun” way).
First I need a web server that I can query from Observable. I started out with the exact example code from the warp
docs:
use warp::Filter;
#[tokio::main]
async fn main() {
// GET /hello/warp => 200 OK with body "Hello, warp!"
let hello = warp::path!("hello" / String)
.map(|name| format!("Hello, {}!", name));
warp::serve(hello)
.run(([127, 0, 0, 1], 3030))
.await;
}
I then added a block to my Observable notebook:
(await fetch('http://localhost:3030/hello/there')).text()
TypeError: Failed to fetch
Oh no! It failed. It’s likely due to CORS, as detailed in the soFetch notebook, so I’ll have to figure out how to allow cross-origin requests on my simple server.
We can add some CORS handling like this:
let cors = warp::cors()
.allow_origin("https://pcarleton.static.observableusercontent.com")
.allow_methods(vec!["GET"]);
// GET /hello/warp => 200 OK with body "Hello, warp!"
let hello = warp::path!("hello" / String)
.map(|name| format!("Hello, {}!", name))
.with(cors);
And voila!
(await fetch('http://localhost:3030/hello/there')).text()
"Hello, there!"
Querying
Now we just need to access bean query.
To do that, I’ll use a crate called duct
which has some convenience functions for calling other commands:
fn execute_query(query: &String) -> std::io::Result<String> {
cmd!("bean-query", "-f", "csv", BEAN_PATH, query).read()
}
Then we just need to add some URI decoding so we can accept the query through the URL:
fn wrap_execute(query: &String) -> String {
let decoded = match urlencoding::decode(query) {
Ok(s) => s,
Err(_) => return String::from("error decoding query")
};
match execute_query(&decoded) {
Ok(s) => s,
Err(e) => e.to_string()
}
}
#[tokio::main]
async fn main() {
let cors = warp::cors()
.allow_origin("https://pcarleton.static.observableusercontent.com")
.allow_methods(vec!["GET"]);
// GET /query/$QUERY_STRING
let query = warp::path!("query" / String)
# this is the new part
.map(|q| wrap_execute(&q))
.with(cors);
warp::serve(query)
.run(([127, 0, 0, 1], 3030))
.await;
}
Then we do some encodeURI
in our notebook and we’re off to the races:
query = `SELECT
account, CONVERT(sum(position), 'USD') as position
FROM
date > 2020-01-01 AND date < 2022-01-01
group by account`
(await fetch(`http://localhost:3030/query/${encodeURI(query)}`)).text()
This still returns in CSV format, where I still want to add a JSON conversion layer.
Other notes
I found this StackOverflow post about debugging CORS issues useful, the tl;dr is if set the Origin
header like curl -H "Origin: myorigin.com"
it should simulate a cross-origin situation.
I also learned that I needed to use localhost
and not 127.0.0.1
to query with fetch
. I didn’t dig too deeply into why that is.
If this was useful to you, let me know on Twitter!