Skip to content

Commit

Permalink
Add Api trait
Browse files Browse the repository at this point in the history
This should abstract away from the actual implementation and allow
for reproducible, local tests with a DummyApi.
Tests have been rewritten to use the DummyApi.
Prep for #11.
  • Loading branch information
MalteT committed Oct 25, 2021
1 parent ca84c76 commit b21f921
Show file tree
Hide file tree
Showing 10 changed files with 220 additions and 573 deletions.
452 changes: 0 additions & 452 deletions Cargo.lock

Large diffs are not rendered by default.

1 change: 0 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,5 @@ itertools = "0.10"

[dev-dependencies]
temp-dir = "0.1"
httpmock = "0.6"
pretty_assertions = "1.0"
assert_cmd = "2.0"
97 changes: 15 additions & 82 deletions src/cache/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,8 @@
use cacache::Metadata;
use chrono::{Duration, TimeZone};
use lazy_static::lazy_static;
use regex::Regex;
use reqwest::{blocking::Response, StatusCode, Url};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use reqwest::{StatusCode, Url};
use serde::de::DeserializeOwned;
use tracing::{info, warn};

mod fetchable;
Expand All @@ -45,30 +44,13 @@ pub use fetchable::Fetchable;
pub use wrapper::clear_cache as clear;

use crate::{
config::CONF,
error::{Error, Result, ResultExt},
request::{Api, DefaultApi, Headers, Response},
};

/// Returned by most functions in this module.
type TextAndHeaders = (String, Headers);

lazy_static! {
/// Regex to find the next page in a link header
/// Probably only applicable to the current version of the openmensa API.
// TODO: Improve this. How do these LINK headers look in general?
static ref LINK_NEXT_PAGE_RE: Regex = Regex::new(r#"<([^>]*)>; rel="next""#).unwrap();
}

/// Assortment of headers relevant to the program.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Headers {
pub etag: Option<String>,
pub this_page: Option<usize>,
pub next_page: Option<String>,
pub last_page: Option<usize>,
}

/// Possible results from a cache load.
#[derive(Debug, PartialEq)]
enum CacheResult<T> {
Expand Down Expand Up @@ -175,32 +157,26 @@ fn get_and_update_cache(
etag: Option<String>,
meta: Option<Metadata>,
) -> Result<TextAndHeaders> {
// Construct the request
let mut builder = CONF.client.get(url);
// Add If-None-Match header, if etag is present
if let Some(etag) = etag {
let etag_key = reqwest::header::IF_NONE_MATCH;
builder = builder.header(etag_key, etag);
lazy_static! {
static ref API: DefaultApi = DefaultApi::create().expect("Failed to create API");
}
let resp = wrapper::send_request(builder)?;
let status = resp.status();
info!("Request to {:?} returned {}", url, status);
// Send request with optional ETag header
let resp = API.get(url, etag)?;
info!("Request to {:?} returned {}", url, resp.status);
match meta {
Some(meta) if status == StatusCode::NOT_MODIFIED => {
Some(meta) if resp.status == StatusCode::NOT_MODIFIED => {
// If we received code 304 NOT MODIFIED (after adding the If-None-Match)
// our cache is actually fresh and it's timestamp should be updated
let headers = resp.headers().clone().into();
// Just verified, that meta can be unwrapped!
touch_and_load_cache(url, &meta, headers)
touch_and_load_cache(url, &meta, resp.headers)
}
_ if status.is_success() => {
_ if resp.status.is_success() => {
// Request returned successfully, now update the cache with that
update_cache_from_response(resp)
}
_ => {
// Some error occured, just error out
// TODO: Retrying would be an option
Err(Error::NonSuccessStatusCode(url.to_string(), resp.status()))
Err(Error::NonSuccessStatusCode(url.to_string(), resp.status))
}
}
}
Expand All @@ -209,11 +185,9 @@ fn get_and_update_cache(
///
/// Only relevant headers will be kept.
fn update_cache_from_response(resp: Response) -> Result<TextAndHeaders> {
let headers: Headers = resp.headers().clone().into();
let url = resp.url().as_str().to_owned();
let text = resp.text().map_err(Error::Reqwest)?;
wrapper::write_cache(&headers, &url, &text)?;
Ok((text, headers))
let url = resp.url.to_owned();
wrapper::write_cache(&resp.headers, &url, &resp.body)?;
Ok((resp.body, resp.headers))
}

/// Reset the cache's TTL, load and return it.
Expand Down Expand Up @@ -248,44 +222,3 @@ fn to_text_and_headers(raw: Vec<u8>, meta: &serde_json::Value) -> Result<TextAnd
})?;
Ok((utf8, headers))
}

impl From<reqwest::header::HeaderMap> for Headers {
fn from(map: reqwest::header::HeaderMap) -> Self {
use reqwest::header::*;
let etag = map
.get(ETAG)
.map(|raw| {
let utf8 = raw.to_str().ok()?;
Some(utf8.to_string())
})
.flatten();
let this_page = map
.get("x-current-page")
.map(|raw| {
let utf8 = raw.to_str().ok()?;
utf8.parse().ok()
})
.flatten();
let next_page = map
.get(LINK)
.map(|raw| {
let utf8 = raw.to_str().ok()?;
let captures = LINK_NEXT_PAGE_RE.captures(utf8)?;
Some(captures[1].to_owned())
})
.flatten();
let last_page = map
.get("x-total-pages")
.map(|raw| {
let utf8 = raw.to_str().ok()?;
utf8.parse().ok()
})
.flatten();
Self {
etag,
this_page,
last_page,
next_page,
}
}
}
28 changes: 10 additions & 18 deletions src/cache/tests.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use std::thread;

use httpmock::{Method::GET, MockServer};
use lazy_static::lazy_static;
use pretty_assertions::assert_eq;

use super::*;
Expand Down Expand Up @@ -33,39 +33,31 @@ fn test_cache_is_empty() {

#[test]
fn basic_caching() {
// === Setup ===
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/test");
then.status(200)
.header("ETag", "static")
.body("This page works!");
});

let url = "http://invalid.local/test";
// Cache is empty
let val = try_load_cache(&server.url("/test"), Duration::max_value()).unwrap();
let val = try_load_cache(url, Duration::max_value()).unwrap();
print_cache_list("After first read");
assert_eq!(val, CacheResult::Miss);
// Populate the cache with the first request
let val = fetch(server.url("/test"), *TTL, |txt, _| Ok(txt)).unwrap();
assert_eq!(val, "This page works!",);
let val = fetch(url, *TTL, |txt, _| Ok(txt)).unwrap();
assert_eq!(val, "It works",);
// The cache should now be hit
let val = try_load_cache(&server.url("/test"), Duration::max_value()).unwrap();
let val = try_load_cache(url, Duration::max_value()).unwrap();
print_cache_list("After second read");
assert_eq!(
val,
CacheResult::Hit((
"This page works!".into(),
"It works".into(),
Headers {
etag: Some("static".into()),
this_page: None,
this_page: Some(1),
next_page: None,
last_page: None,
last_page: Some(1),
}
))
);
// Let's fake a stale entry
thread::sleep(std::time::Duration::from_secs(1));
let val = try_load_cache(&server.url("/test"), Duration::zero()).unwrap();
let val = try_load_cache(url, Duration::zero()).unwrap();
assert!(matches!(val, CacheResult::Stale(_, _)));
}
5 changes: 0 additions & 5 deletions src/cache/wrapper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
//! To make testing easier.
use cacache::Metadata;
use lazy_static::lazy_static;
use reqwest::blocking::{RequestBuilder, Response};
use tracing::info;

use std::{io::Write, path::Path};
Expand Down Expand Up @@ -42,10 +41,6 @@ pub fn clear_cache() -> Result<()> {
cacache::clear_sync(cache()).map_err(|why| Error::Cache(why, "clearing"))
}

pub fn send_request(builder: RequestBuilder) -> Result<Response> {
builder.send().map_err(Error::Reqwest)
}

#[cfg(test)]
pub fn list_cache() -> impl Iterator<Item = cacache::Result<Metadata>> {
cacache::list_sync(cache())
Expand Down
19 changes: 4 additions & 15 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
use chrono::NaiveDate;
use lazy_static::lazy_static;
use reqwest::blocking::Client;
use serde::Deserialize;
use structopt::{clap::arg_enum, StructOpt};

use std::{collections::HashSet, fs, path::Path, time::Duration as StdDuration};
use std::{collections::HashSet, fs, path::Path};

use crate::{
canteen::CanteenId,
Expand All @@ -22,32 +21,22 @@ pub mod args;
pub mod rule;

lazy_static! {
pub static ref CONF: Config = Config::assemble().unwrap();
static ref REQUEST_TIMEOUT: StdDuration = StdDuration::from_secs(10);
pub static ref CONF: Config = Config::assemble();
}

#[derive(Debug)]
pub struct Config {
pub config: Option<ConfigFile>,
pub client: Client,
pub args: Args,
}

impl Config {
fn assemble() -> Result<Self> {
fn assemble() -> Self {
let args = Args::from_args();
let default_config_path = || DIR.config_dir().join("config.toml");
let path = args.config.clone().unwrap_or_else(default_config_path);
let config = ConfigFile::load_or_log(path);
let client = Client::builder()
.timeout(*REQUEST_TIMEOUT)
.build()
.map_err(Error::Reqwest)?;
Ok(Config {
config,
client,
args,
})
Config { config, args }
}

/// Easy reference to the Command
Expand Down
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ mod error;
mod geoip;
mod meal;
mod pagination;
mod request;
mod tag;
// #[cfg(test)]
// mod tests;
Expand Down
47 changes: 47 additions & 0 deletions src/request/dummy.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//! This contains the [`DummyApi`] used for testing purposes.
use reqwest::StatusCode;

use crate::error::Result;

use super::{Api, Headers, Response};

/// A dummy API, serving local, deterministic Responses
#[derive(Debug)]
pub struct DummyApi;

impl Api for DummyApi {
fn create() -> Result<Self> {
Ok(DummyApi)
}

fn get<'url, S>(&self, url: &'url str, etag: Option<S>) -> Result<Response<'url>>
where
S: AsRef<str>,
{
if url == "http://invalid.local/test" {
get_test_page(etag)
} else {
panic!("BUG: Invalid url in dummy api: {:?}", url)
}
}
}

/// GET http://invalid.local/test
fn get_test_page<S: AsRef<str>>(etag: Option<S>) -> Result<Response<'static>> {
let etag = etag.map(|etag| etag.as_ref().to_owned());
Ok(Response {
url: "http://invalid.local/test",
status: if etag == Some("static".into()) {
StatusCode::NOT_MODIFIED
} else {
StatusCode::OK
},
headers: Headers {
etag: Some("static".into()),
this_page: Some(1),
next_page: None,
last_page: Some(1),
},
body: "It works".to_owned(),
})
}
51 changes: 51 additions & 0 deletions src/request/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
use ::reqwest::StatusCode;
use serde::{Deserialize, Serialize};

use crate::error::Result;

#[cfg(not(test))]
mod reqwest;
#[cfg(not(test))]
pub use self::reqwest::ReqwestApi as DefaultApi;

#[cfg(test)]
mod dummy;
#[cfg(test)]
pub use self::dummy::DummyApi as DefaultApi;

/// Assortment of headers relevant to the program.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Headers {
pub etag: Option<String>,
pub this_page: Option<usize>,
pub next_page: Option<String>,
pub last_page: Option<usize>,
}

/// A subset of a Response, derived from [`reqwest::Response`].
pub struct Response<'url> {
pub url: &'url str,
pub status: StatusCode,
pub headers: Headers,
pub body: String,
}

/// Generalized API endpoint.
///
/// This abstracts away from the real thing to allow for deterministic local
/// tests with a DummyApi.
pub trait Api
where
Self: Sized,
{
/// Create the Api.
fn create() -> Result<Self>;

/// Send a get request.
///
/// Optionally attach an `If-None-Match` header, if `etag` is `Some`.
fn get<'url, S>(&self, url: &'url str, etag: Option<S>) -> Result<Response<'url>>
where
S: AsRef<str>;
}
Loading

0 comments on commit b21f921

Please sign in to comment.