use crate::config::Config;
use crate::edit_distance::{edit_distance, find_best_match_for_name};
use crate::generate::rust::{write_arglist_no_delimiters, write_type};
use crate::util::{add_auth_header_opt, database_address, get_auth_header_only};
use anyhow::{bail, Context, Error};
use clap::{Arg, ArgAction, ArgMatches};
use itertools::Either;
use serde_json::Value;
use spacetimedb_lib::de::serde::deserialize_from;
use spacetimedb_lib::sats::{AlgebraicTypeRef, Typespace};
use spacetimedb_lib::ProductTypeElement;
use std::fmt::Write;
use std::iter;
pub fn cli() -> clap::Command {
clap::Command::new("call")
.about("Invokes a reducer function in a database")
.arg(
Arg::new("database")
.required(true)
.help("The database domain or address to use to invoke the call"),
)
.arg(
Arg::new("reducer_name")
.required(true)
.help("The name of the reducer to call"),
)
.arg(
Arg::new("arguments")
.help("arguments as a JSON array")
.default_value("[]"),
)
.arg(
Arg::new("as_identity")
.long("as-identity")
.short('i')
.conflicts_with("anon_identity")
.help("The identity to use for the call"),
)
.arg(
Arg::new("anon_identity")
.long("anon-identity")
.short('a')
.conflicts_with("as_identity")
.action(ArgAction::SetTrue)
.help("If this flag is present, the call will be executed with no identity provided"),
)
.after_help("Run `spacetime help call` for more detailed information.\n")
}
pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), Error> {
let database = args.get_one::<String>("database").unwrap();
let reducer_name = args.get_one::<String>("reducer_name").unwrap();
let arg_json = args.get_one::<String>("arguments").unwrap();
let as_identity = args.get_one::<String>("as_identity");
let anon_identity = args.get_flag("anon_identity");
let address = database_address(&config, database).await?;
let builder = reqwest::Client::new().post(format!(
"{}/database/call/{}/{}",
config.get_host_url(),
address,
reducer_name
));
let auth_header = get_auth_header_only(&mut config, anon_identity, as_identity).await;
let builder = add_auth_header_opt(builder, &auth_header);
let res = builder.body(arg_json.to_owned()).send().await?;
if let Err(e) = res.error_for_status_ref() {
let Ok(response_text) = res.text().await else {
bail!(e);
};
let error = Err(e).context(format!("Response text: {}", response_text));
let error_msg = if response_text.starts_with("no such reducer") {
no_such_reducer(config, &address, database, &auth_header, reducer_name).await
} else if response_text.starts_with("invalid arguments") {
invalid_arguments(config, &address, database, &auth_header, reducer_name, &response_text).await
} else {
return error;
};
return error.context(error_msg);
}
Ok(())
}
async fn invalid_arguments(
config: Config,
addr: &str,
db: &str,
auth_header: &Option<String>,
reducer: &str,
text: &str,
) -> String {
let mut error = format!(
"Invalid arguments provided for reducer `{}` for database `{}` resolving to address `{}`.",
reducer, db, addr
);
if let Some((actual, expected)) = find_actual_expected(text).filter(|(a, e)| a != e) {
write!(
error,
"\n\n{} parameters were expected, but {} were provided.",
expected, actual
)
.unwrap();
}
if let Some(sig) = schema_json(config, addr, auth_header, true)
.await
.and_then(|schema| reducer_signature(schema, reducer))
{
write!(error, "\n\nThe reducer has the following signature:\n\t{}", sig).unwrap();
}
error
}
fn find_actual_expected(text: &str) -> Option<(usize, usize)> {
let (_, x) = split_at_first_substring(text, "invalid length")?;
let (x, y) = split_at_first_substring(x, "args for test with")?;
let (x, _) = split_at_first_substring(x, ",")?;
let (y, _) = split_at_first_substring(y, "elements")?;
let actual: usize = x.trim().parse().ok()?;
let expected: usize = y.trim().parse().ok()?;
Some((actual, expected))
}
fn split_at_first_substring<'t>(text: &'t str, substring: &str) -> Option<(&'t str, &'t str)> {
text.find(substring)
.map(|pos| (&text[..pos], &text[pos + substring.len()..]))
}
fn reducer_signature(schema_json: Value, reducer_name: &str) -> Option<String> {
let typespace = typespace(&schema_json)?;
let elements = find_of_type_in_schema(&schema_json, "reducer")
.find(|(name, _)| *name == reducer_name)?
.1
.get("schema")?
.get("elements")?;
let params = deserialize_from::<Vec<ProductTypeElement>, _>(elements).ok()?;
let mut args = String::new();
fn ctx(typespace: &Typespace, r: AlgebraicTypeRef) -> String {
let ty = &typespace[r];
let mut ty_str = String::new();
write_type(&|r| ctx(typespace, r), &mut ty_str, ty);
ty_str
}
write_arglist_no_delimiters(&|r| ctx(&typespace, r), &mut args, ¶ms, None);
let args = args.trim().trim_end_matches(',').replace('\n', " ");
let mut reducer_fmt = String::new();
write!(&mut reducer_fmt, "{}({})", reducer_name, args).unwrap();
Some(reducer_fmt)
}
async fn no_such_reducer(config: Config, addr: &str, db: &str, auth_header: &Option<String>, reducer: &str) -> String {
let mut error = format!(
"No such reducer `{}` for database `{}` resolving to address `{}`.",
reducer, db, addr
);
if let Some(schema) = schema_json(config, addr, auth_header, false).await {
add_reducer_ctx_to_err(&mut error, schema, reducer);
}
error
}
const REDUCER_PRINT_LIMIT: usize = 10;
fn add_reducer_ctx_to_err(error: &mut String, schema_json: Value, reducer_name: &str) {
let mut reducers = find_of_type_in_schema(&schema_json, "reducer")
.map(|kv| kv.0)
.collect::<Vec<_>>();
reducers.retain(|&c| !matches!(c, "__update__" | "__init__"));
if let Some(best) = find_best_match_for_name(&reducers, reducer_name, None) {
write!(error, "\n\nA reducer with a similar name exists: `{}`", best).unwrap();
} else if reducers.is_empty() {
write!(error, "\n\nThe database has no reducers.").unwrap();
} else {
reducers.sort_by_key(|candidate| edit_distance(reducer_name, candidate, usize::MAX));
let too_many_to_show = reducers.len() > REDUCER_PRINT_LIMIT;
let diff = reducers.len().abs_diff(REDUCER_PRINT_LIMIT);
reducers.truncate(REDUCER_PRINT_LIMIT);
write!(error, "\n\nHere are some existing reducers:").unwrap();
for candidate in reducers {
write!(error, "\n- {}", candidate).unwrap();
}
if too_many_to_show {
let plural = if diff == 1 { "" } else { "s" };
write!(error, "\n... ({} reducer{} not shown)", diff, plural).unwrap();
}
}
}
async fn schema_json(config: Config, address: &str, auth_header: &Option<String>, expand: bool) -> Option<Value> {
let builder = reqwest::Client::new().get(format!("{}/database/schema/{}", config.get_host_url(), address));
let builder = add_auth_header_opt(builder, auth_header);
builder
.query(&[("expand", expand)])
.send()
.await
.ok()?
.json::<serde_json::Value>()
.await
.ok()
}
fn find_of_type_in_schema<'v, 't: 'v>(
value: &'v serde_json::Value,
ty: &'t str,
) -> impl Iterator<Item = (&'v str, &'v Value)> {
let Some(entities) = value
.as_object()
.and_then(|o| o.get("entities"))
.and_then(|e| e.as_object())
else {
return Either::Left(iter::empty());
};
let iter = entities
.into_iter()
.filter(move |(_, value)| {
let Some(obj) = value.as_object() else { return false; };
obj.get("type").filter(|x| x.as_str() == Some(ty)).is_some()
})
.map(|(key, value)| (key.as_str(), value));
Either::Right(iter)
}
fn typespace(value: &serde_json::Value) -> Option<Typespace> {
let types = value.as_object()?.get("typespace")?;
deserialize_from(types).map(Typespace::new).ok()
}