sofie-cli 0.4.0

The `sofie` command: gen, check and the tools reading the app model.
Documentation
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");
/// Replayed in turn: enough variety for an index or a cache to see different
/// rows, few enough to keep the script small.
const BODIES: usize = 32;
/// Fixed, so the script is the same on every run and in review.
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>,
}

/// One endpoint per GET route and answer: a route with a screen answers
/// HTML and JSON, any other GET answers JSON. A route creating a record from
/// an input is loaded too, posting JSON bodies made from the input's fields.
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)
    }

    /// Names differing only where `metric_name` writes `_` stay apart: `a.b_c` and `a_b.c`.
    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);
        }
    }
}

/// The route's full path and how its area is signed in to, or why it is not loaded.
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()),
    })
}

/// Where an area is signed in to; the account comes from k6's environment.
pub(crate) fn member(paths: &SignInPaths) -> Value {
    json!({
        "signIn": paths.sign_in,
        "signUp": paths.sign_up,
    })
}

/// Each path parameter takes the first record the principal's own list at
/// the path before it answers, so the load reads reachable rows. A list is
/// keyed by its area and path, since two lists may fill a parameter of one name.
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))
}

/// What `sofie seed` fills, so a write needs no record to exist first.
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
        );
    }
}