mod functions;
#[cfg(test)]
mod tests;
mod util;
#[cfg(any(test, feature = "test"))]
use crate::collection::Recipe;
use crate::{
collection::{
Collection, Profile, ProfileId, RecipeId, RenderedValue, ValueTemplate,
},
http::{
Exchange, RequestSeed, ResponseRecord, StoredRequestError,
TriggeredRequestError,
},
render::{
functions::RequestTrigger,
util::{FieldCache, FieldCacheOutcome},
},
};
use async_trait::async_trait;
use chrono::Utc;
use derive_more::derive::Display;
use futures::{StreamExt, TryFutureExt};
use indexmap::IndexMap;
use itertools::Itertools;
use serde::Deserialize;
use slumber_template::{
Arguments, Context, Identifier, RenderError, StreamSource, Value,
ValueStream,
};
use std::{
fmt::Debug, io, iter, path::PathBuf, process::ExitStatus, sync::Arc,
};
use thiserror::Error;
#[derive(Debug)]
pub struct TemplateContext {
pub collection: Arc<Collection>,
pub selected_profile: Option<ProfileId>,
pub http_provider: Box<dyn HttpProvider>,
pub overrides: IndexMap<String, ValueTemplate>,
pub prompter: Box<dyn Prompter>,
pub show_sensitive: bool,
pub root_dir: PathBuf,
pub state: RenderGroupState,
}
impl TemplateContext {
fn current_profile(&self) -> Option<&Profile> {
self.selected_profile
.as_ref()
.and_then(|id| self.collection.profiles.get(id))
}
async fn get_field_cache_guard(
&self,
field: &Identifier,
) -> FieldCacheOutcome {
self.state.field_cache.get_or_init(field.clone()).await
}
fn get_field_template(
&self,
field: &Identifier,
) -> Result<&ValueTemplate, RenderError> {
self
.overrides
.get(field.as_str())
.or_else(|| {
let profile = self.current_profile()?;
profile.data.get(field.as_str())
})
.ok_or_else(|| {
FunctionError::UnknownField {
field: field.to_string(),
}
.into()
})
}
async fn get_latest_response(
&self,
recipe_id: &RecipeId,
trigger: RequestTrigger,
) -> Result<Arc<ResponseRecord>, FunctionError> {
if self.collection.recipes.get_recipe(recipe_id).is_none() {
return Err(FunctionError::RecipeUnknown {
recipe_id: recipe_id.clone(),
});
}
let exchange = match trigger {
RequestTrigger::Never => self
.get_latest_cached(recipe_id)
.await?
.ok_or(FunctionError::ResponseMissing)?,
RequestTrigger::NoHistory => {
if let Some(exchange) =
self.get_latest_cached(recipe_id).await?
{
exchange
} else {
self.send_request(recipe_id).await?
}
}
RequestTrigger::Expire { duration } => {
match self.get_latest_cached(recipe_id).await? {
Some(exchange)
if exchange.end_time + duration.inner()
>= Utc::now() =>
{
exchange
}
_ => self.send_request(recipe_id).await?,
}
}
RequestTrigger::Always => self.send_request(recipe_id).await?,
};
Ok(exchange.response)
}
async fn get_latest_cached(
&self,
recipe_id: &RecipeId,
) -> Result<Option<Exchange>, FunctionError> {
self.http_provider
.get_latest_request(self.selected_profile.as_ref(), recipe_id)
.await
.map_err(FunctionError::StoredRequest)
}
async fn send_request(
&self,
recipe_id: &RecipeId,
) -> Result<Exchange, FunctionError> {
let build_options = Default::default();
self.http_provider
.send_request(
RequestSeed::new(recipe_id.clone(), build_options),
self,
)
.await
.map_err(|error| FunctionError::Trigger {
recipe_id: recipe_id.clone(),
error,
})
}
}
impl Context<Value> for TemplateContext {
async fn get_field(
&self,
field: &Identifier,
) -> Result<Value, RenderError> {
let guard = match self.get_field_cache_guard(field).await {
FieldCacheOutcome::Hit(value) => return Ok(value),
FieldCacheOutcome::Miss(guard) => guard,
};
let template = self.get_field_template(field)?;
let chunks = template.render_value(self).await; let value = chunks
.try_into_value()
.map_err(|error| field_error(error, field))?;
guard.set(value.clone());
Ok(value)
}
async fn call(
&self,
function_name: &Identifier,
arguments: Arguments<'_, Self>,
) -> Result<Value, RenderError> {
<Self as Context<ValueStream>>::call(self, function_name, arguments)
.and_then(ValueStream::resolve)
.await
}
}
impl Context<ValueStream> for TemplateContext {
async fn get_field(
&self,
field: &Identifier,
) -> Result<ValueStream, RenderError> {
let guard = match self.get_field_cache_guard(field).await {
FieldCacheOutcome::Hit(value) => return Ok(value.into()),
FieldCacheOutcome::Miss(guard) => guard,
};
let template = self.get_field_template(field)?;
let output = template.render_value_stream(self).await;
if output.has_stream() {
match output {
RenderedValue::Value(result) => result,
RenderedValue::Chunks(chunks) => {
let stream = chunks
.try_into_stream()
.map_err(|error| field_error(error, field))?;
Ok(ValueStream::Stream {
source: StreamSource::Compound,
stream: stream.boxed(),
})
}
}
} else {
let value = output
.try_collect_value()
.await
.map_err(|error| field_error(error, field))?;
guard.set(value.clone());
Ok(ValueStream::Value(value))
}
}
async fn call(
&self,
function_name: &Identifier,
arguments: Arguments<'_, Self>,
) -> Result<ValueStream, RenderError> {
match function_name.as_str() {
"base64" => functions::base64(arguments),
"boolean" => functions::boolean(arguments),
"command" => functions::command(arguments),
"concat" => functions::concat(arguments),
"debug" => functions::debug(arguments),
"env" => functions::env(arguments),
"file" => functions::file(arguments),
"float" => functions::float(arguments),
"index" => functions::index(arguments),
"integer" => functions::integer(arguments),
"join" => functions::join(arguments),
"jq" => functions::jq(arguments),
"json_parse" => functions::json_parse(arguments),
"jsonpath" => functions::jsonpath(arguments),
"lower" => functions::lower(arguments),
"prompt" => functions::prompt(arguments).await,
"replace" => functions::replace(arguments),
"response" => functions::response(arguments).await,
"response_header" => functions::response_header(arguments).await,
"select" => functions::select(arguments).await,
"sensitive" => functions::sensitive(arguments),
"slice" => functions::slice(arguments),
"split" => functions::split(arguments),
"string" => functions::string(arguments),
"trim" => functions::trim(arguments),
"upper" => functions::upper(arguments),
_ => Err(RenderError::FunctionUnknown),
}
}
}
#[cfg(any(test, feature = "test"))]
impl slumber_util::Factory for TemplateContext {
fn factory((): ()) -> Self {
Self::factory((IndexMap::new(), IndexMap::new()))
}
}
#[cfg(any(test, feature = "test"))]
impl slumber_util::Factory<Profile> for TemplateContext {
fn factory(profile: Profile) -> Self {
use crate::test_util::by_id;
let profile_id = profile.id.clone();
let collection = Collection {
profiles: by_id([profile]),
..Collection::factory(())
};
TemplateContext {
collection: collection.into(),
selected_profile: Some(profile_id),
..TemplateContext::factory(())
}
}
}
#[cfg(any(test, feature = "test"))]
impl
slumber_util::Factory<(
IndexMap<ProfileId, Profile>,
IndexMap<RecipeId, Recipe>,
)> for TemplateContext
{
fn factory(
(profiles, recipes): (
IndexMap<ProfileId, Profile>,
IndexMap<RecipeId, Recipe>,
),
) -> Self {
use crate::{
database::CollectionDatabase,
test_util::{TestHttpProvider, TestPrompter},
};
use slumber_util::test_data_dir;
let selected_profile = profiles.first().map(|(id, _)| id.clone());
Self {
collection: Collection {
name: None,
recipes: recipes.into(),
profiles,
}
.into(),
selected_profile,
http_provider: Box::new(TestHttpProvider::new(
CollectionDatabase::factory(()),
None,
)),
overrides: IndexMap::new(),
prompter: Box::<TestPrompter>::default(),
root_dir: test_data_dir(),
show_sensitive: true,
state: Default::default(),
}
}
}
#[derive(Debug, Default)]
pub struct RenderGroupState {
field_cache: FieldCache,
}
#[async_trait(?Send)] pub trait HttpProvider: Debug {
async fn get_latest_request(
&self,
profile_id: Option<&ProfileId>,
recipe_id: &RecipeId,
) -> Result<Option<Exchange>, StoredRequestError>;
async fn send_request(
&self,
seed: RequestSeed,
template_context: &TemplateContext,
) -> Result<Exchange, TriggeredRequestError>;
}
#[async_trait(?Send)]
pub trait Prompter: Debug {
async fn prompt_text(
&self,
message: String,
default: Option<String>,
sensitive: bool,
) -> Option<String>;
async fn prompt_select(
&self,
message: String,
options: Vec<SelectOption>,
) -> Option<Value>;
}
#[derive(Clone, Debug, Display, Deserialize)]
#[display("{label}")]
pub struct SelectOption {
pub label: String,
pub value: Value,
}
#[derive(Debug, Error)]
pub enum FunctionError {
#[error(transparent)]
Base64Decode(#[from] base64::DecodeError),
#[error(
"Executing command `{}`", iter::once(program).chain(arguments).format(" ")
)]
CommandInit {
program: String,
arguments: Vec<String>,
#[source]
error: io::Error,
},
#[error(
"Command `{command}` exited with {status}",
command = iter::once(program).chain(arguments).format(" "),
)]
CommandStatus {
program: String,
arguments: Vec<String>,
status: ExitStatus,
},
#[error("Command must have at least one element")]
CommandEmpty,
#[error("Reading file `{path}`")]
File {
path: PathBuf,
#[source]
error: io::Error,
},
#[error(transparent)]
InvalidUtf8(#[from] std::string::FromUtf8Error),
#[error("{0}")]
Jq(String),
#[error("Error parsing JSON")]
JsonParse(
#[from]
#[source]
serde_json::Error,
),
#[error("No results from JSON query `{query}`")]
JsonQueryNoResults { query: String },
#[error(
"Expected exactly one result from JSON query `{query}`, \
but got {actual_count}"
)]
JsonQueryTooMany { query: String, actual_count: usize },
#[error("Rendering profile field `{field}`")]
ProfileNested {
field: Identifier,
#[source]
error: RenderError,
},
#[error("No reply from prompt")]
PromptNoReply,
#[error("Unknown recipe `{recipe_id}`")]
RecipeUnknown { recipe_id: RecipeId },
#[error(transparent)]
Regex(#[from] regex::Error),
#[error("No response available")]
ResponseMissing,
#[error("Header `{header}` not in response")]
ResponseMissingHeader { header: String },
#[error("Select has no options")]
SelectNoOptions,
#[error("No reply from select")]
SelectNoReply,
#[error(transparent)]
StoredRequest(StoredRequestError),
#[error("Triggering upstream recipe `{recipe_id}`")]
Trigger {
recipe_id: RecipeId,
#[source]
error: TriggeredRequestError,
},
#[error("Unknown profile field `{field}`")]
UnknownField { field: String },
}
impl From<FunctionError> for RenderError {
fn from(error: FunctionError) -> Self {
RenderError::other(error)
}
}
fn field_error(error: RenderError, field: &Identifier) -> RenderError {
RenderError::from(FunctionError::ProfileNested {
field: field.clone(),
error,
})
}