use serde_json::Value;
use snafu::ResultExt;
use crate::decode;
use crate::error::{Result, error};
use crate::transport::{Call, SpecFetch, TapesTransport};
pub const DISCOVERY_PATH: &str = "/v1/cassettes";
const NOT_MODIFIED: u16 = 304;
pub async fn invoke<T: TapesTransport>(transport: &T, call: &Call<'_>) -> Result<Value> {
let response = transport.send(call).await.context(error::TransportSnafu)?;
decode::json(&response)
}
pub async fn fetch_discovery<T: TapesTransport>(transport: &T) -> Result<Value> {
invoke(
transport,
&Call {
method: "GET",
path: DISCOVERY_PATH,
..Default::default()
},
)
.await
}
pub async fn fetch_spec<T: TapesTransport>(
transport: &T,
path: &str,
etag: Option<&str>,
) -> Result<SpecFetch> {
guard_spec_path(path)?;
let mut call = Call {
method: "GET",
path,
..Default::default()
};
if let Some(etag) = etag {
call.headers
.push(("if-none-match".to_owned(), etag.to_owned()));
}
let response = transport.send(&call).await.context(error::TransportSnafu)?;
if response.status == NOT_MODIFIED {
return Ok(SpecFetch::Unchanged);
}
let etag = response.header("etag").map(ToOwned::to_owned);
let document = decode::json(&response)?;
Ok(SpecFetch::Fetched { document, etag })
}
pub fn guard_spec_path(path: &str) -> Result<()> {
if !path.starts_with('/') || path.starts_with("//") {
return error::SpecPathSnafu {
path: path.to_owned(),
}
.fail();
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use crate::transport::{TransportError, WireRequest, WireResponse};
use std::cell::RefCell;
type Seen = Vec<(String, Vec<(String, String)>)>;
struct Canned {
status: u16,
headers: Vec<(String, String)>,
body: Vec<u8>,
seen: RefCell<Seen>,
}
impl Canned {
fn new(status: u16, body: &str) -> Self {
Self {
status,
headers: Vec::new(),
body: body.as_bytes().to_vec(),
seen: RefCell::new(Vec::new()),
}
}
fn with_header(mut self, name: &str, value: &str) -> Self {
self.headers.push((name.to_owned(), value.to_owned()));
self
}
}
impl TapesTransport for Canned {
async fn send(
&self,
request: &WireRequest<'_>,
) -> std::result::Result<WireResponse, TransportError> {
self.seen
.borrow_mut()
.push((request.path.to_owned(), request.headers.clone()));
Ok(WireResponse::new(
self.status,
format!("http://tapes.test{}", request.path),
self.headers.clone(),
self.body.clone(),
))
}
}
#[tokio::test]
async fn a_spec_path_may_not_change_the_request_authority() {
let transport = Canned::new(200, "{}");
for path in ["//evil.example/spec.json", "relative/spec.json", ""] {
let err = fetch_spec(&transport, path, None).await.unwrap_err();
assert!(
err.to_string().contains("non-relative OpenAPI path"),
"{path:?} produced the wrong error: {err}",
);
}
assert!(
transport.seen.borrow().is_empty(),
"a refused path must never reach the transport",
);
}
#[tokio::test]
async fn a_matched_validator_is_unchanged_rather_than_an_error() {
let transport = Canned::new(304, "");
let got = fetch_spec(
&transport,
"/v1/cassettes/x/openapi.json",
Some("\"sha256:abc\""),
)
.await
.unwrap();
assert!(matches!(got, SpecFetch::Unchanged), "got: {got:?}");
let seen = transport.seen.borrow();
assert_eq!(
seen[0].1,
vec![("if-none-match".to_owned(), "\"sha256:abc\"".to_owned())],
"the validator must travel with the request",
);
}
#[tokio::test]
async fn a_fetched_spec_carries_the_validator_for_next_time() {
let transport =
Canned::new(200, r#"{"openapi":"3.1.0"}"#).with_header("ETag", "\"sha256:def\"");
let got = fetch_spec(&transport, "/v1/cassettes/x/openapi.json", None)
.await
.unwrap();
match got {
SpecFetch::Fetched { document, etag } => {
assert_eq!(document["openapi"], "3.1.0");
assert_eq!(etag.as_deref(), Some("\"sha256:def\""));
}
SpecFetch::Unchanged => panic!("expected a document"),
}
}
#[tokio::test]
async fn an_error_body_is_surfaced_with_the_status() {
let transport = Canned::new(400, r#"{"error":"invalid cursor"}"#);
let err = fetch_discovery(&transport).await.unwrap_err();
let rendered = format!("{err}");
assert!(rendered.contains("400"), "got: {rendered}");
assert!(rendered.contains("invalid cursor"), "got: {rendered}");
}
#[tokio::test]
async fn a_successful_empty_body_is_null_not_a_decode_failure() {
let transport = Canned::new(204, "");
assert_eq!(fetch_discovery(&transport).await.unwrap(), Value::Null);
}
#[tokio::test]
async fn discovery_asks_for_the_documented_route() {
let transport = Canned::new(200, "{}");
let _ = fetch_discovery(&transport).await.unwrap();
assert_eq!(transport.seen.borrow()[0].0, DISCOVERY_PATH);
}
}