use std::sync::LazyLock;
use crate::cassettes::spec::{self, Location, Method, ReducerConfig};
use crate::transport::Call;
use serde_json::Value;
use snafu::OptionExt;
use crate::error::{Result, error};
pub const TAPES_API_YAML: &str = include_str!("../../contracts/tapes-api.yaml");
pub mod ops {
pub const LIST_SESSIONS: &str = "listSessions";
pub const GET_SESSION: &str = "getSession";
pub const GET_SESSION_TRACES: &str = "getSessionTraces";
pub const LIST_RAW_TURNS: &str = "listRawTurns";
pub const EXPORT_SESSION: &str = "exportSession";
pub const LIST_TRACES: &str = "listTraces";
pub const GET_TRACE: &str = "getTrace";
pub const GET_SPAN: &str = "getSpan";
pub const SEARCH_SPANS: &str = "searchSpans";
pub const SEED_DEMO: &str = "seedDemo";
pub const LIST_CASSETTES: &str = "listCassettes";
pub const UPDATE_SESSION: &str = "updateSession";
pub const DELETE_SESSION: &str = "deleteSession";
pub const EXPORT_SESSIONS: &str = "exportSessions";
pub const LIST_SESSION_SKILLS: &str = "listSessionSkills";
pub const GET_STATS: &str = "getStats";
pub const LIST_SKILLS: &str = "listSkills";
pub const CREATE_SKILL: &str = "createSkill";
pub const GET_SKILL: &str = "getSkill";
pub const UPDATE_SKILL: &str = "updateSkill";
pub const DELETE_SKILL: &str = "deleteSkill";
pub const DUPLICATE_SKILL: &str = "duplicateSkill";
pub const LIST_SKILL_VERSIONS: &str = "listSkillVersions";
pub const PUBLISH_SKILL: &str = "publishSkill";
pub const GENERATE_SKILL: &str = "generateSkill";
}
#[derive(Debug)]
pub struct CoreSurface {
methods: Vec<Method>,
}
impl CoreSurface {
#[must_use]
pub fn reduce(reducer: &ReducerConfig<'_>) -> Option<Self> {
Self::from_yaml(TAPES_API_YAML, reducer)
}
fn from_yaml(yaml: &str, reducer: &ReducerConfig<'_>) -> Option<Self> {
let document: Value = serde_yaml::from_str(yaml).ok()?;
let methods = spec::reduce_methods(&document, reducer);
if methods.is_empty() {
return None;
}
Some(Self { methods })
}
pub fn method(&self, operation_id: &str) -> Result<&Method> {
self.methods
.iter()
.find(|method| method.operation_id.as_deref() == Some(operation_id))
.context(error::ContractOperationSnafu {
operation: operation_id,
})
}
pub fn operation_ids(&self) -> impl Iterator<Item = &str> {
self.methods
.iter()
.filter_map(|method| method.operation_id.as_deref())
}
}
static CORE: LazyLock<Option<CoreSurface>> =
LazyLock::new(|| CoreSurface::from_yaml(TAPES_API_YAML, &ReducerConfig::default()));
pub fn core() -> Result<&'static CoreSurface> {
CORE.as_ref().context(error::VendoredContractSnafu {
surface: "tapes-api",
})
}
pub fn call_for<'m>(method: &'m Method, values: Vec<(&str, String)>) -> Result<Call<'m>> {
call_for_with_body(method, values, None)
}
pub fn call_for_with_body<'m>(
method: &'m Method,
values: Vec<(&str, String)>,
body: Option<String>,
) -> Result<Call<'m>> {
let operation = || {
method
.operation_id
.clone()
.unwrap_or_else(|| method.name.clone())
};
let mut call = Call {
method: &method.http_method,
path: &method.path,
..Default::default()
};
for (wire, value) in values {
let declared = method
.params
.iter()
.find(|param| param.wire == wire)
.with_context(|| error::ContractParameterSnafu {
operation: operation(),
parameter: wire,
})?;
let pair = (declared.wire.clone(), value);
match declared.location {
Location::Path => call.path_params.push(pair),
Location::Query => call.query.push(pair),
Location::Header => call.headers.push(pair),
}
}
for param in &method.params {
let supplied = match param.location {
Location::Path => &call.path_params,
Location::Query => &call.query,
Location::Header => &call.headers,
}
.iter()
.any(|(name, _)| *name == param.wire);
if supplied {
continue;
}
match param.location {
Location::Path => {
return error::ContractPathParameterSnafu {
operation: operation(),
parameter: param.wire.clone(),
}
.fail();
}
Location::Query if param.required => {
return error::ContractRequiredParameterSnafu {
operation: operation(),
parameter: param.wire.clone(),
location: "query",
}
.fail();
}
Location::Header if param.required => {
return error::ContractRequiredParameterSnafu {
operation: operation(),
parameter: param.wire.clone(),
location: "header",
}
.fail();
}
Location::Query | Location::Header => {}
}
}
match (method.body, body) {
(Some(true), None) => {
return error::ContractBodySnafu {
operation: operation(),
detail: "requires a request body and none was supplied",
}
.fail();
}
(None, Some(_)) => {
return error::ContractBodySnafu {
operation: operation(),
detail: "declares no request body, so one cannot be sent",
}
.fail();
}
(_, supplied) => call.body = supplied,
}
Ok(call)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
#[test]
fn the_vendored_contract_parses_and_reduces() {
let surface = core().expect("contracts/tapes-api.yaml must parse");
assert!(surface.operation_ids().count() > 0);
}
#[test]
fn an_unknown_operation_is_an_error_not_a_guessed_route() {
let err = core().unwrap().method("launchMissiles").unwrap_err();
assert!(err.to_string().contains("launchMissiles"), "got: {err}");
}
#[test]
fn a_value_is_routed_by_the_contracts_declared_location() {
let surface = core().unwrap();
let method = surface.method(ops::GET_SESSION_TRACES).unwrap();
let call = call_for(
method,
vec![("id", "s-1".to_owned()), ("payload", "preview".to_owned())],
)
.unwrap();
assert_eq!(call.method, "GET");
assert_eq!(call.path, "/v1/sessions/{id}/traces");
assert_eq!(call.path_params, vec![("id".to_owned(), "s-1".to_owned())]);
assert_eq!(
call.query,
vec![("payload".to_owned(), "preview".to_owned())]
);
}
#[test]
fn an_undeclared_parameter_is_refused_before_any_request() {
let surface = core().unwrap();
let method = surface.method(ops::GET_SESSION).unwrap();
let err = call_for(
method,
vec![("id", "s-1".to_owned()), ("payolad", "full".to_owned())],
)
.unwrap_err();
assert!(err.to_string().contains("payolad"), "got: {err}");
}
#[test]
fn a_missing_path_parameter_is_refused_because_no_url_could_be_built() {
let surface = core().unwrap();
let method = surface.method(ops::GET_SPAN).unwrap();
let err = call_for(method, vec![("trace_id", "t-1".to_owned())]).unwrap_err();
assert!(err.to_string().contains("span_id"), "got: {err}");
}
#[test]
fn a_missing_required_query_parameter_is_refused_like_a_missing_path_one() {
let surface = core().unwrap();
for (operation, missing) in [
(ops::SEARCH_SPANS, "query"),
(ops::LIST_TRACES, "session_id"),
] {
let method = surface.method(operation).unwrap();
let err = call_for(method, Vec::new()).unwrap_err();
assert!(
err.to_string().contains(missing),
"{operation} must name {missing:?}: {err}",
);
assert!(
err.to_string().contains("query parameter"),
"{operation} must say where the parameter travels: {err}",
);
}
}
#[test]
fn supplying_a_required_query_parameter_is_all_that_is_asked() {
let surface = core().unwrap();
let call = call_for(
surface.method(ops::SEARCH_SPANS).unwrap(),
vec![("query", "gum glow charm".to_owned())],
)
.unwrap();
assert_eq!(call.path, "/v1/search/spans");
assert_eq!(
call.query,
vec![("query".to_owned(), "gum glow charm".to_owned())],
);
}
#[test]
fn an_optional_parameter_left_unset_is_still_simply_omitted() {
let surface = core().unwrap();
let call = call_for(surface.method(ops::LIST_SESSIONS).unwrap(), Vec::new()).unwrap();
assert!(call.query.is_empty(), "got: {:?}", call.query);
}
#[test]
fn an_operation_that_requires_a_body_is_refused_without_one() {
let surface = core().unwrap();
let method = surface.method("createSkill").unwrap();
let err = call_for(method, Vec::new()).unwrap_err();
assert!(
err.to_string().contains("requires a request body"),
"got: {err}",
);
}
#[test]
fn an_operation_that_declares_no_body_refuses_one() {
let surface = core().unwrap();
let method = surface.method(ops::GET_SESSION).unwrap();
let err = call_for_with_body(
method,
vec![("id", "s-1".to_owned())],
Some("{}".to_owned()),
)
.unwrap_err();
assert!(
err.to_string().contains("declares no request body"),
"got: {err}",
);
}
#[test]
fn a_required_body_is_carried_on_the_call_when_it_is_supplied() {
let surface = core().unwrap();
let method = surface.method("createSkill").unwrap();
let call =
call_for_with_body(method, Vec::new(), Some(r#"{"name":"x"}"#.to_owned())).unwrap();
assert_eq!(call.method, "POST");
assert_eq!(call.body.as_deref(), Some(r#"{"name":"x"}"#));
}
#[test]
fn an_optional_body_may_be_present_or_absent() {
let surface = core().unwrap();
let method = surface.method(ops::SEED_DEMO).unwrap();
assert_eq!(call_for(method, Vec::new()).unwrap().body, None);
assert_eq!(
call_for_with_body(method, Vec::new(), Some("{}".to_owned()))
.unwrap()
.body
.as_deref(),
Some("{}"),
);
}
#[test]
fn every_named_operation_id_resolves_in_the_vendored_contract() {
let surface = core().unwrap();
for id in [
ops::LIST_SESSIONS,
ops::GET_SESSION,
ops::GET_SESSION_TRACES,
ops::LIST_RAW_TURNS,
ops::EXPORT_SESSION,
ops::LIST_TRACES,
ops::GET_TRACE,
ops::GET_SPAN,
ops::SEARCH_SPANS,
ops::SEED_DEMO,
ops::LIST_CASSETTES,
ops::UPDATE_SESSION,
ops::DELETE_SESSION,
ops::EXPORT_SESSIONS,
ops::LIST_SESSION_SKILLS,
ops::GET_STATS,
ops::LIST_SKILLS,
ops::CREATE_SKILL,
ops::GET_SKILL,
ops::UPDATE_SKILL,
ops::DELETE_SKILL,
ops::DUPLICATE_SKILL,
ops::LIST_SKILL_VERSIONS,
ops::PUBLISH_SKILL,
ops::GENERATE_SKILL,
] {
assert!(surface.method(id).is_ok(), "{id:?} did not resolve");
}
}
#[test]
fn a_reducer_configuration_changes_presentation_without_moving_a_wire_name() {
let reserved = ReducerConfig {
reserved_flags: &["limit", "id", "help"],
};
let mine = CoreSurface::reduce(&reserved).unwrap();
let theirs = core().unwrap();
let wires = |surface: &CoreSurface, id: &str| -> Vec<(String, Location)> {
surface
.method(id)
.unwrap()
.params
.iter()
.map(|p| (p.wire.clone(), p.location))
.collect()
};
assert_eq!(
wires(&mine, ops::LIST_SESSIONS),
wires(theirs, ops::LIST_SESSIONS),
);
let flags: Vec<&str> = mine
.method(ops::LIST_SESSIONS)
.unwrap()
.params
.iter()
.map(|p| p.flag.as_str())
.collect();
assert!(flags.contains(&"param-limit"), "got: {flags:?}");
}
}