use std::collections::BTreeMap;
use serde_json::{Map, Value, json};
use sofie_model::naming::{RouteSite, path_parameters};
use sofie_model::{App, Method, Modifier, SignInPaths};
use sofie_values::Seed;
use crate::gen_command::GenError;
use crate::inputs::valid_object;
use crate::targets::{Access, IDENTITY, access, input_fields};
pub(crate) const HEADER: &str = "// @generated by `sofie bench`; do not edit.\n";
const RUNNER: &str = include_str!("runner.js");
const BODIES: usize = 32;
const BODY_SEED: u64 = 1;
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct Script {
pub text: String,
pub skipped: Vec<String>,
pub member_areas: Vec<String>,
}
#[derive(Default)]
struct Plan {
endpoints: Vec<Value>,
areas: BTreeMap<String, Value>,
collections: Vec<Value>,
skipped: Vec<String>,
metrics: Vec<String>,
}
pub(crate) fn bench_script(app: &App) -> Result<Script, GenError> {
let all = app.route_sites();
let sites: Vec<&RouteSite> = all
.iter()
.filter(|site| site.route.method == Method::Get && site.route.webhook.is_none())
.collect();
let mut plan = Plan::default();
for site in &sites {
if let Err(reason) = plan.add(app, site, &sites)? {
plan.skipped.push(format!("{}: {reason}", site.name()));
}
}
let mut seed = Seed::new(BODY_SEED);
for site in all.iter().filter(|site| creates_from_input(site)) {
if let Err(reason) = plan.add_write(app, site, &mut seed)? {
plan.skipped.push(format!("{}: {reason}", site.name()));
}
}
let data = format!(
"const ENDPOINTS = {};\nconst AREAS = {};\nconst COLLECTIONS = {};\n",
pretty(&Value::from(plan.endpoints)),
pretty(&json!(plan.areas)),
pretty(&Value::from(plan.collections)),
);
Ok(Script {
text: format!("{HEADER}{data}{RUNNER}"),
skipped: plan.skipped,
member_areas: plan.areas.into_keys().collect(),
})
}
impl Plan {
fn add(
&mut self,
app: &App,
site: &RouteSite,
sites: &[&RouteSite],
) -> Result<Result<(), String>, GenError> {
if site.route.modifiers.contains(&Modifier::Signed) {
return Ok(Err(
"a signed link carries a signature sofie bench cannot make".to_owned(),
));
}
let (path, sign_in) = match reach(app, site)? {
Ok(reached) => reached,
Err(reason) => return Ok(Err(reason)),
};
let area = site.area.area.as_str();
let (collections, ids) = match collections(app, area, &path, sites) {
Ok(collections) => collections,
Err(reason) => return Ok(Err(reason)),
};
for collection in collections {
if !self.collections.contains(&collection) {
self.collections.push(collection);
}
}
let session_area = self.sign_in(area, sign_in);
self.add_endpoints(site, &path, &ids, session_area);
Ok(Ok(()))
}
fn add_write(
&mut self,
app: &App,
site: &RouteSite,
seed: &mut Seed,
) -> Result<Result<(), String>, GenError> {
let (path, sign_in) = match reach(app, site)? {
Ok(reached) => reached,
Err(reason) => return Ok(Err(reason)),
};
let session_area = self.sign_in(&site.area.area, sign_in);
let fields = input_fields(site);
let bodies: Vec<Value> = (0..BODIES)
.map(|_| Value::Object(valid_object(&fields, seed)))
.collect();
let endpoint = format!("{} json", site.name());
let metric = self.metric(&endpoint);
self.endpoints.push(json!({
"name": endpoint,
"metric": metric,
"method": "POST",
"path": path,
"accept": "application/json",
"area": session_area,
"idempotent": site.route.modifiers.contains(&Modifier::Idempotent),
"bodies": bodies,
}));
Ok(Ok(()))
}
fn sign_in<'a>(&mut self, area: &'a str, paths: Option<SignInPaths>) -> Option<&'a str> {
self.areas.insert(area.to_owned(), member(&paths?));
Some(area)
}
fn metric(&mut self, endpoint: &str) -> String {
let base = metric_name(endpoint);
let mut metric = base.clone();
let mut number = 1;
while self.metrics.contains(&metric) {
number += 1;
metric = format!("{base}_{number}");
}
self.metrics.push(metric.clone());
metric
}
fn add_endpoints(
&mut self,
site: &RouteSite,
path: &str,
ids: &Map<String, Value>,
session_area: Option<&str>,
) {
let name = site.name();
let answers: &[(&str, &str)] = if site.route.screen.is_some() {
&[("html", "text/html"), ("json", "application/json")]
} else {
&[("json", "application/json")]
};
for (answer, accept) in answers {
let endpoint = format!("{name} {answer}");
let metric = self.metric(&endpoint);
let mut described = json!({
"name": endpoint,
"metric": metric,
"method": "GET",
"path": path,
"accept": accept,
"area": session_area,
});
if !ids.is_empty() {
described["ids"] = Value::Object(ids.clone());
}
self.endpoints.push(described);
}
}
}
fn reach(
app: &App,
site: &RouteSite,
) -> Result<Result<(String, Option<SignInPaths>), String>, GenError> {
let area = &site.area.area;
let Some(path) = site.full_path(app) else {
return Ok(Err(format!("area `{area}` has no literal prefix")));
};
Ok(match access(app, area)? {
Access::Anonymous => Ok((path, None)),
Access::Member(paths) => Ok((path, Some(paths))),
Access::Unreachable(reason) => Err(reason.to_owned()),
})
}
pub(crate) fn member(paths: &SignInPaths) -> Value {
json!({
"signIn": paths.sign_in,
"signUp": paths.sign_up,
})
}
fn collections(
app: &App,
area: &str,
path: &str,
sites: &[&RouteSite],
) -> Result<(Vec<Value>, Map<String, Value>), String> {
let mut ids = Map::new();
let mut collections = Vec::new();
for parameter in path_parameters(path) {
let marker = format!("/{{{parameter}}}");
let Some(position) = path.find(&marker) else {
return Err(format!("`{{*{parameter}}}` takes no record id"));
};
let collection = &path[..position];
let listed = sites.iter().any(|site| {
site.area.area == area && site.full_path(app).as_deref() == Some(collection)
});
if !listed {
return Err(format!(
"no GET {collection} in area `{area}` to take a `{parameter}` from"
));
}
let key = format!("{area} {collection}");
collections.push(json!({ "key": key, "area": area, "path": collection, "ids": ids }));
ids.insert(parameter.to_owned(), Value::from(key));
}
Ok((collections, ids))
}
fn creates_from_input(site: &RouteSite) -> bool {
site.route.method == Method::Post
&& site.route.webhook.is_none()
&& path_parameters(&site.route.path).is_empty()
&& site.module.krate.crate_name != IDENTITY
&& !input_fields(site).is_empty()
}
pub(crate) fn metric_name(endpoint: &str) -> String {
endpoint
.chars()
.map(|character| {
if character.is_ascii_alphanumeric() {
character
} else {
'_'
}
})
.collect()
}
fn pretty(value: &Value) -> String {
serde_json::to_string_pretty(value).expect("a JSON value serializes")
}
#[cfg(test)]
mod tests {
use std::path::Path;
use sofie_model::testing::{app, area, define_area, module, route};
use sofie_model::{Method, Modifier};
use super::bench_script;
#[test]
fn matches_the_shops_committed_script() {
let shop = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/shop");
let app = sofie_model::load(&shop).unwrap();
let script = bench_script(&app).unwrap();
assert_eq!(script.skipped, Vec::<String>::new());
assert_eq!(script.member_areas, ["customer"]);
assert!(!script.text.contains(crate::local_app::MEMBER_PASSWORD));
assert_eq!(
script.text,
include_str!("../../../../examples/shop/bench/gen/endpoints.js"),
"{}",
script.text
);
}
#[test]
fn skips_what_it_cannot_reach_and_takes_ids_from_the_list_before_them() {
let mut order = module("order", "shop/modules/order");
let mut signed = route(
Method::Get,
"/orders/{order}/receipt",
"queries::receipt",
3,
);
signed.modifiers.push(Modifier::Signed);
let routes = vec![
route(Method::Get, "/orders", "queries::list_orders", 1),
route(Method::Get, "/orders/{order}", "queries::show_order", 2),
signed,
route(Method::Get, "/orphans/{orphan}", "queries::show_orphan", 4),
];
order.areas.push(area(&order, "web", routes));
define_area(&mut order, "member");
let members = vec![route(Method::Get, "/orders", "queries::list_orders", 1)];
order.areas.push(area(&order, "member", members));
let script = bench_script(&app("shop", vec![order])).unwrap();
let collection = "\"area\": \"web\",\n \"ids\": {},\n \"key\": \"web /orders\",\n \"path\": \"/orders\"";
assert!(script.text.contains(collection), "{}", script.text);
assert!(
script
.text
.contains("\"name\": \"order.web.show_order json\"")
);
assert_eq!(script.skipped.len(), 3, "{:?}", script.skipped);
assert!(script.skipped[0].contains("only to sofie-identity's member areas"));
assert!(script.skipped[1].contains("signed link"));
assert!(script.skipped[2].contains("no GET /orphans in area `web`"));
}
#[test]
fn a_parameter_named_alike_in_two_collections_takes_each_one_s_id() {
let mut order = module("order", "shop/modules/order");
let routes = vec![
route(Method::Get, "/orders", "queries::list_orders", 1),
route(Method::Get, "/orders/{item}", "queries::show_order", 2),
route(Method::Get, "/carts", "queries::list_carts", 3),
route(Method::Get, "/carts/{item}", "queries::show_cart", 4),
route(Method::Get, "/web-x", "queries::web_x", 5),
];
order.areas.push(area(&order, "web", routes));
let mut order_web = module("order_web", "shop/modules/order_web");
let others = vec![route(Method::Get, "/x", "queries::x", 1)];
order_web.areas.push(area(&order_web, "web", others));
let script = bench_script(&app("shop", vec![order, order_web])).unwrap();
let endpoints = script.text.split("const AREAS").next().unwrap();
assert!(
endpoints.contains("\"ids\": {\n \"item\": \"web /orders\"\n },\n \"method\": \"GET\",\n \"metric\": \"order_web_show_order_json\""),
"{}",
script.text
);
assert!(
endpoints.contains("\"ids\": {\n \"item\": \"web /carts\"\n }"),
"{}",
script.text
);
assert!(
script.text.contains("\"key\": \"web /carts\""),
"{}",
script.text
);
assert!(
endpoints.contains("\"metric\": \"order_web_web_x_json\"")
&& endpoints.contains("\"metric\": \"order_web_web_x_json_2\""),
"{}",
script.text
);
}
}