feat: add reviewed household records and unit validation
This commit is contained in:
9
docs/api-coverage.md
Normal file
9
docs/api-coverage.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# API coverage
|
||||||
|
|
||||||
|
Catalog baseline: Grocy 4.7.1. All remote operations use the enrolled `/api` prefix, including files and diagnostics. Record fields are a reviewed factual catalog derived from that version's schema; new server fields require a catalog update.
|
||||||
|
|
||||||
|
Household records cover products/barcodes/groups, locations, units/conversions, shopping lists/items/stores, recipes/ingredients/nesting, meal plans/sections, chores, tasks/categories, batteries, equipment and custom fields/entities/objects. Supported history and stock views are read-only.
|
||||||
|
|
||||||
|
Account mutation, API keys, sessions, permissions, configuration secrets, public sharing links, external lookup and printing are excluded. Stock-unit changes require a positive unambiguous Grocy conversion. Grocy 4.7.1's database cascade rescales stock and related records; its API result is read back. These preflight checks do not prevent simultaneous edits by other clients.
|
||||||
|
|
||||||
|
Source: [Grocy API specification](https://github.com/grocy/grocy/blob/master/grocy.openapi.json).
|
||||||
2489
src/catalog/data.json
Normal file
2489
src/catalog/data.json
Normal file
File diff suppressed because it is too large
Load Diff
249
src/catalog/mod.rs
Normal file
249
src/catalog/mod.rs
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
use crate::{
|
||||||
|
client::HttpMethod,
|
||||||
|
error::{AppError, Result},
|
||||||
|
model::{AccessMode, Page},
|
||||||
|
};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::{collections::BTreeMap, sync::OnceLock};
|
||||||
|
pub mod schemas;
|
||||||
|
fn raw() -> &'static Value {
|
||||||
|
static RAW: OnceLock<Value> = OnceLock::new();
|
||||||
|
RAW.get_or_init(|| {
|
||||||
|
serde_json::from_str(include_str!("data.json")).expect("reviewed catalog JSON")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pub fn entities() -> &'static serde_json::Map<String, Value> {
|
||||||
|
raw()["entities"].as_object().expect("entity catalog")
|
||||||
|
}
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct OperationSpec {
|
||||||
|
pub name: &'static str,
|
||||||
|
pub route: &'static str,
|
||||||
|
pub method: HttpMethod,
|
||||||
|
pub schema: Value,
|
||||||
|
pub mutates: bool,
|
||||||
|
pub destructive: bool,
|
||||||
|
pub description: String,
|
||||||
|
pub path_params: BTreeMap<String, String>,
|
||||||
|
pub body_keys: Vec<String>,
|
||||||
|
}
|
||||||
|
pub struct ValidatedCall {
|
||||||
|
pub op: OperationSpec,
|
||||||
|
pub args: Value,
|
||||||
|
pub page: Page,
|
||||||
|
}
|
||||||
|
fn schema(properties: Value, required: Value) -> Value {
|
||||||
|
json!({"type":"object","properties":properties,"required":required,"additionalProperties":false})
|
||||||
|
}
|
||||||
|
pub fn operations(mode: AccessMode) -> Vec<OperationSpec> {
|
||||||
|
let mut ops = Vec::new();
|
||||||
|
let enum_entities = entities().keys().collect::<Vec<_>>();
|
||||||
|
let writable = entities()
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, v)| v["writable"] == true)
|
||||||
|
.map(|(n, _)| n)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
for (name, method, route, description) in [
|
||||||
|
(
|
||||||
|
"records_describe",
|
||||||
|
HttpMethod::Get,
|
||||||
|
"/objects/{entity}",
|
||||||
|
"Describe allowed household fields and required creation fields before changing records.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"records_list",
|
||||||
|
HttpMethod::Get,
|
||||||
|
"/objects/{entity}",
|
||||||
|
"List household records with actual data and pagination. Use entity and optional offset/limit.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"records_lookup",
|
||||||
|
HttpMethod::Get,
|
||||||
|
"/objects/{entity}",
|
||||||
|
"Find exact record names. Returns every matching candidate; never silently picks duplicate names.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"records_get",
|
||||||
|
HttpMethod::Get,
|
||||||
|
"/objects/{entity}/{id}",
|
||||||
|
"Read one household record by positive numeric ID.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"records_create",
|
||||||
|
HttpMethod::Post,
|
||||||
|
"/objects/{entity}",
|
||||||
|
"Create a household record using fields from records_describe.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"records_update",
|
||||||
|
HttpMethod::Put,
|
||||||
|
"/objects/{entity}/{id}",
|
||||||
|
"Edit only supplied fields. Stock-unit changes must use verified supported conversion procedures.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"records_delete",
|
||||||
|
HttpMethod::Delete,
|
||||||
|
"/objects/{entity}/{id}",
|
||||||
|
"Permanently delete the precise household entity and ID. Grocy constraints may reject dependent records.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"userfields_get",
|
||||||
|
HttpMethod::Get,
|
||||||
|
"/userfields/{entity}/{id}",
|
||||||
|
"Read custom-field values for a household record.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"userfields_update",
|
||||||
|
HttpMethod::Put,
|
||||||
|
"/userfields/{entity}/{id}",
|
||||||
|
"Update named custom-field values for a household record; discover their definitions using records_list userfields.",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let writes = method.mutates();
|
||||||
|
if writes && mode == AccessMode::ReadOnly {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut properties =
|
||||||
|
json!({"entity":{"type":"string","enum":if writes{&writable}else{&enum_entities}}});
|
||||||
|
let mut required = vec!["entity"];
|
||||||
|
if route.contains("{id}") {
|
||||||
|
properties["id"] = json!({"type":"integer","minimum":1});
|
||||||
|
required.push("id");
|
||||||
|
}
|
||||||
|
if name == "records_lookup" {
|
||||||
|
properties["name"] = json!({"type":"string","maxLength":256});
|
||||||
|
required.push("name");
|
||||||
|
}
|
||||||
|
if name == "records_list" {
|
||||||
|
properties["offset"] = json!({"type":"integer","minimum":0});
|
||||||
|
properties["limit"] = json!({"type":"integer","minimum":1,"maximum":500});
|
||||||
|
}
|
||||||
|
if writes && method != HttpMethod::Delete {
|
||||||
|
properties["fields"] = json!({"type":"object"});
|
||||||
|
required.push("fields");
|
||||||
|
}
|
||||||
|
let mut params = BTreeMap::from([("entity".into(), "entity".into())]);
|
||||||
|
if route.contains("{id}") {
|
||||||
|
params.insert("id".into(), "id".into());
|
||||||
|
}
|
||||||
|
ops.push(OperationSpec {
|
||||||
|
name,
|
||||||
|
route,
|
||||||
|
method,
|
||||||
|
schema: schema(properties, json!(required)),
|
||||||
|
mutates: writes,
|
||||||
|
destructive: method == HttpMethod::Delete,
|
||||||
|
description: description.into(),
|
||||||
|
path_params: params,
|
||||||
|
body_keys: vec![],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for v in raw()["operations"].as_array().expect("operation catalog") {
|
||||||
|
let method = match v["method"].as_str().unwrap() {
|
||||||
|
"get" => HttpMethod::Get,
|
||||||
|
"post" => HttpMethod::Post,
|
||||||
|
"put" => HttpMethod::Put,
|
||||||
|
"delete" => HttpMethod::Delete,
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
|
if method.mutates() && mode == AccessMode::ReadOnly {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name = v["name"].as_str().unwrap();
|
||||||
|
let mut properties = v["properties"].clone();
|
||||||
|
if name.starts_with("stock_")
|
||||||
|
&& ["amount", "new_amount"]
|
||||||
|
.iter()
|
||||||
|
.any(|k| properties.get(k).is_some())
|
||||||
|
{
|
||||||
|
properties["quantity_unit_id"] = json!({"type":"integer","minimum":1,"description":"Optional quantity unit ID. Omit to use this product's stock unit. A valid Grocy conversion is required."});
|
||||||
|
}
|
||||||
|
let path_params =
|
||||||
|
serde_json::from_value(v["path_params"].clone()).expect("path parameters");
|
||||||
|
let body_keys = serde_json::from_value(v["body_keys"].clone()).expect("body keys");
|
||||||
|
let description = format!(
|
||||||
|
"Grocy {}. Read IDs using record tools. Quantities use the product's stock unit unless quantity_unit_id is explicitly supplied with a valid conversion. Dates: YYYY-MM-DD; timestamps: RFC3339 or server-local YYYY-MM-DD HH:MM:SS. Paging defaults to 100, maximum 500. Writes return receipts and one readback where available. UNKNOWN_WRITE_OUTCOME means inspect current state before any repeat; never replay automatically. Recipe consumption can consume only available ingredients.",
|
||||||
|
name.replace('_', " ")
|
||||||
|
);
|
||||||
|
ops.push(OperationSpec {
|
||||||
|
name,
|
||||||
|
route: v["route"].as_str().unwrap(),
|
||||||
|
method,
|
||||||
|
schema: schema(properties, v["required"].clone()),
|
||||||
|
mutates: method.mutates(),
|
||||||
|
destructive: name.contains("clear")
|
||||||
|
|| name.contains("merge")
|
||||||
|
|| name.contains("consume"),
|
||||||
|
description,
|
||||||
|
path_params,
|
||||||
|
body_keys,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ops
|
||||||
|
}
|
||||||
|
pub fn validate(name: &str, args: Value, mode: AccessMode) -> Result<ValidatedCall> {
|
||||||
|
if serde_json::to_vec(&args)
|
||||||
|
.map_err(|_| AppError::input())?
|
||||||
|
.len()
|
||||||
|
> crate::client::JSON_LIMIT
|
||||||
|
{
|
||||||
|
return Err(AppError::input());
|
||||||
|
}
|
||||||
|
let op = operations(mode)
|
||||||
|
.into_iter()
|
||||||
|
.find(|o| o.name == name)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AppError::new(
|
||||||
|
"UNKNOWN_TOOL",
|
||||||
|
"This tool is unavailable for the selected access mode.",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
schemas::check(&args, &op.schema)?;
|
||||||
|
if let Some(amount) = args.get("amount") {
|
||||||
|
if amount
|
||||||
|
.as_number()
|
||||||
|
.is_some_and(|n| n.as_f64().is_some_and(|n| n <= 0.0))
|
||||||
|
|| amount.as_str().is_some_and(|n| {
|
||||||
|
n.parse::<rust_decimal::Decimal>()
|
||||||
|
.is_ok_and(|n| n <= rust_decimal::Decimal::ZERO)
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return Err(AppError::input());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if name.starts_with("records_") || name.starts_with("userfields_") {
|
||||||
|
let entity = args["entity"].as_str().ok_or_else(AppError::input)?;
|
||||||
|
let spec = entities().get(entity).ok_or_else(AppError::input)?;
|
||||||
|
if let Some(fields) = args.get("fields") {
|
||||||
|
if name == "userfields_update" {
|
||||||
|
if fields.as_object().is_none_or(|f| {
|
||||||
|
f.is_empty()
|
||||||
|
|| f.keys()
|
||||||
|
.any(|k| k.is_empty() || k.len() > 128 || k.contains(['/', '\\', '\0']))
|
||||||
|
}) {
|
||||||
|
return Err(AppError::input());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if spec["writable"] != true {
|
||||||
|
return Err(AppError::input());
|
||||||
|
}
|
||||||
|
let required = if name == "records_create" {
|
||||||
|
spec["required"].clone()
|
||||||
|
} else {
|
||||||
|
json!([])
|
||||||
|
};
|
||||||
|
schemas::check(fields, &schema(spec["fields"].clone(), required))?;
|
||||||
|
if fields.as_object().is_none_or(|f| f.is_empty()) {
|
||||||
|
return Err(AppError::input());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let offset = args["offset"].as_u64().unwrap_or(0);
|
||||||
|
let limit = args["limit"].as_u64().unwrap_or(100);
|
||||||
|
let page = Page::new(
|
||||||
|
usize::try_from(offset).map_err(|_| AppError::input())?,
|
||||||
|
usize::try_from(limit).map_err(|_| AppError::input())?,
|
||||||
|
)?;
|
||||||
|
Ok(ValidatedCall { op, args, page })
|
||||||
|
}
|
||||||
94
src/catalog/schemas.rs
Normal file
94
src/catalog/schemas.rs
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
use crate::error::{AppError, Result};
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::str::FromStr;
|
||||||
|
pub fn check(value: &Value, schema: &Value) -> Result<()> {
|
||||||
|
if value.is_null() && schema["nullable"] == true {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if let Some(en) = schema["enum"].as_array() {
|
||||||
|
if !en.contains(value) {
|
||||||
|
return Err(AppError::input());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let typ = schema["type"].as_str().unwrap_or("");
|
||||||
|
match typ {
|
||||||
|
"object" => {
|
||||||
|
let obj = value.as_object().ok_or_else(AppError::input)?;
|
||||||
|
if let Some(req) = schema["required"].as_array() {
|
||||||
|
for k in req {
|
||||||
|
if !obj.contains_key(k.as_str().ok_or_else(AppError::input)?) {
|
||||||
|
return Err(AppError::input());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(props) = schema["properties"].as_object() {
|
||||||
|
for (k, v) in obj {
|
||||||
|
match props.get(k) {
|
||||||
|
Some(s) => check(v, s)?,
|
||||||
|
None if schema["additionalProperties"] == false => {
|
||||||
|
return Err(AppError::input());
|
||||||
|
}
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"array" => {
|
||||||
|
let arr = value.as_array().ok_or_else(AppError::input)?;
|
||||||
|
if arr.len() > schema["maxItems"].as_u64().unwrap_or(500) as usize {
|
||||||
|
return Err(AppError::input());
|
||||||
|
}
|
||||||
|
for item in arr {
|
||||||
|
check(item, &schema["items"])?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"integer" | "number" => {
|
||||||
|
let s = if value.is_number() {
|
||||||
|
value.to_string()
|
||||||
|
} else if schema["allow_decimal_string"] == true {
|
||||||
|
value.as_str().ok_or_else(AppError::input)?.to_string()
|
||||||
|
} else {
|
||||||
|
return Err(AppError::input());
|
||||||
|
};
|
||||||
|
let n = Decimal::from_str(&s).map_err(|_| AppError::input())?;
|
||||||
|
if typ == "integer" && n.fract() != Decimal::ZERO {
|
||||||
|
return Err(AppError::input());
|
||||||
|
}
|
||||||
|
if let Some(min) = schema["minimum"].as_i64() {
|
||||||
|
if n < Decimal::from(min) {
|
||||||
|
return Err(AppError::input());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(max) = schema["maximum"].as_i64() {
|
||||||
|
if n > Decimal::from(max) {
|
||||||
|
return Err(AppError::input());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"string" => {
|
||||||
|
let s = value.as_str().ok_or_else(AppError::input)?;
|
||||||
|
if s.len() > schema["maxLength"].as_u64().unwrap_or(16384) as usize || s.contains('\0')
|
||||||
|
{
|
||||||
|
return Err(AppError::input());
|
||||||
|
}
|
||||||
|
match schema["format"].as_str() {
|
||||||
|
Some("date") => {
|
||||||
|
chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d")
|
||||||
|
.map_err(|_| AppError::input())?;
|
||||||
|
}
|
||||||
|
Some("date-time") => {
|
||||||
|
if chrono::DateTime::parse_from_rfc3339(s).is_err()
|
||||||
|
&& chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S").is_err()
|
||||||
|
{
|
||||||
|
return Err(AppError::input());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"boolean" if !value.is_boolean() => return Err(AppError::input()),
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
2
src/tools/mod.rs
Normal file
2
src/tools/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod records;
|
||||||
|
pub mod validation;
|
||||||
186
src/tools/records.rs
Normal file
186
src/tools/records.rs
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
use crate::{
|
||||||
|
catalog::{ValidatedCall, entities},
|
||||||
|
client::{ApiClient, ApiRequest, HttpMethod, RequestBody},
|
||||||
|
error::{AppError, Result},
|
||||||
|
model::{ToolOutput, Verification, WriteReport, WriteStatus},
|
||||||
|
};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
pub async fn get_record(client: &ApiClient, entity: &str, id: &Value) -> Result<Value> {
|
||||||
|
let mut r = ApiRequest::get("/objects/{entity}/{id}");
|
||||||
|
r.params = BTreeMap::from([
|
||||||
|
("entity".into(), entity.into()),
|
||||||
|
(
|
||||||
|
"id".into(),
|
||||||
|
id.as_str()
|
||||||
|
.map(str::to_owned)
|
||||||
|
.unwrap_or_else(|| id.to_string()),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
client.request(r).await?.json()
|
||||||
|
}
|
||||||
|
pub async fn run(client: &ApiClient, call: &ValidatedCall) -> Result<ToolOutput> {
|
||||||
|
let entity = call.args["entity"].as_str().ok_or_else(AppError::input)?;
|
||||||
|
if call.op.name == "records_describe" {
|
||||||
|
return Ok(ToolOutput::data(
|
||||||
|
json!({"entity":entity,"definition":entities()[entity],"notes":"Create using required fields; updates contain only changed fields. IDs are explicit. Product units require valid conversions; stock-unit changes require a valid Grocy conversion and use its database cascade."}),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if call.op.mutates {
|
||||||
|
if call.op.method != HttpMethod::Post {
|
||||||
|
get_record(client, entity, &call.args["id"]).await?;
|
||||||
|
}
|
||||||
|
if entity == "products" {
|
||||||
|
for (key, target) in [
|
||||||
|
("location_id", "locations"),
|
||||||
|
("qu_id_stock", "quantity_units"),
|
||||||
|
("qu_id_purchase", "quantity_units"),
|
||||||
|
("qu_id_consume", "quantity_units"),
|
||||||
|
("qu_id_price", "quantity_units"),
|
||||||
|
] {
|
||||||
|
if let Some(id) = call.args["fields"].get(key) {
|
||||||
|
if id.as_u64().is_some_and(|n| n > 0) {
|
||||||
|
get_record(client, target, id).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if call.op.method == HttpMethod::Put && call.args["fields"].get("qu_id_stock").is_some()
|
||||||
|
{
|
||||||
|
let current = get_record(client, "products", &call.args["id"]).await?;
|
||||||
|
let old = super::validation::record_id(¤t["qu_id_stock"])?;
|
||||||
|
let new = super::validation::record_id(&call.args["fields"]["qu_id_stock"])?;
|
||||||
|
if old != new {
|
||||||
|
let product_id = super::validation::record_id(&call.args["id"])?;
|
||||||
|
let conversions =
|
||||||
|
super::validation::load_conversions(client, product_id).await?;
|
||||||
|
super::validation::convert_quantity(
|
||||||
|
rust_decimal::Decimal::ONE,
|
||||||
|
old,
|
||||||
|
new,
|
||||||
|
&conversions,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if call.op.name == "userfields_update" {
|
||||||
|
let mut defs = ApiRequest::get("/objects/{entity}");
|
||||||
|
defs.params.insert("entity".into(), "userfields".into());
|
||||||
|
let rows = client.request(defs).await?.json()?;
|
||||||
|
for key in call.args["fields"]
|
||||||
|
.as_object()
|
||||||
|
.ok_or_else(AppError::input)?
|
||||||
|
.keys()
|
||||||
|
{
|
||||||
|
if !rows
|
||||||
|
.as_array()
|
||||||
|
.ok_or_else(AppError::input)?
|
||||||
|
.iter()
|
||||||
|
.any(|r| {
|
||||||
|
r["entity"].as_str() == Some(entity) && r["name"].as_str() == Some(key)
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return Err(AppError::input());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut request = ApiRequest::get(call.op.route);
|
||||||
|
request.method = call.op.method;
|
||||||
|
for (key, param) in &call.op.path_params {
|
||||||
|
let v = &call.args[key];
|
||||||
|
request.params.insert(
|
||||||
|
param.clone(),
|
||||||
|
v.as_str()
|
||||||
|
.map(str::to_owned)
|
||||||
|
.unwrap_or_else(|| v.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(fields) = call.args.get("fields") {
|
||||||
|
request.body = RequestBody::Json(fields.clone());
|
||||||
|
}
|
||||||
|
if call.op.name == "records_list" {
|
||||||
|
request
|
||||||
|
.query
|
||||||
|
.insert("limit".into(), (call.page.limit + 1).to_string());
|
||||||
|
request
|
||||||
|
.query
|
||||||
|
.insert("offset".into(), call.page.offset.to_string());
|
||||||
|
if entities()[entity]["writable"] == true
|
||||||
|
|| ["stock", "stock_log", "chores_log", "battery_charge_cycles"].contains(&entity)
|
||||||
|
{
|
||||||
|
request.query.insert("order".into(), "id".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let receipt = client.request(request).await?.json()?;
|
||||||
|
if call.op.name == "records_list" {
|
||||||
|
let mut rows = receipt
|
||||||
|
.as_array()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AppError::new("INVALID_RESPONSE", "Expected a list of household records.")
|
||||||
|
})?
|
||||||
|
.clone();
|
||||||
|
let more = rows.len() > call.page.limit;
|
||||||
|
rows.truncate(call.page.limit);
|
||||||
|
return Ok(ToolOutput::data(
|
||||||
|
json!({"items":rows,"has_more":more,"next_offset":if more{Some(call.page.offset+call.page.limit)}else{None}}),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if call.op.name == "records_lookup" {
|
||||||
|
let name = call.args["name"].as_str().ok_or_else(AppError::input)?;
|
||||||
|
let rows = receipt
|
||||||
|
.as_array()
|
||||||
|
.ok_or_else(AppError::input)?
|
||||||
|
.iter()
|
||||||
|
.filter(|r| r["name"].as_str() == Some(name))
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
return Ok(ToolOutput::data(
|
||||||
|
json!({"candidates":rows,"ambiguous":rows.len()>1}),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !call.op.mutates {
|
||||||
|
return Ok(ToolOutput::data(receipt));
|
||||||
|
}
|
||||||
|
let target = json!({"entity":entity,"id":call.args.get("id").or_else(||receipt.get("created_object_id")),"operation":call.op.name});
|
||||||
|
let (data, verification) = if call.op.method == HttpMethod::Delete {
|
||||||
|
let check = get_record(client, entity, &call.args["id"]).await;
|
||||||
|
match check {
|
||||||
|
Err(e) if e.code == "NOT_FOUND" => (json!({"deleted":true}), Verification::Confirmed),
|
||||||
|
_ => (Value::Null, Verification::Incomplete),
|
||||||
|
}
|
||||||
|
} else if let Some(id) = call
|
||||||
|
.args
|
||||||
|
.get("id")
|
||||||
|
.or_else(|| receipt.get("created_object_id"))
|
||||||
|
{
|
||||||
|
let result = if call.op.name == "userfields_update" {
|
||||||
|
let mut r = ApiRequest::get("/userfields/{entity}/{id}");
|
||||||
|
r.params = BTreeMap::from([
|
||||||
|
("entity".into(), entity.into()),
|
||||||
|
("id".into(), id.to_string()),
|
||||||
|
]);
|
||||||
|
match client.request(r).await {
|
||||||
|
Ok(r) => r.json(),
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
get_record(client, entity, id).await
|
||||||
|
};
|
||||||
|
match result {
|
||||||
|
Ok(v) => (v, Verification::Confirmed),
|
||||||
|
Err(_) => (Value::Null, Verification::Incomplete),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(Value::Null, Verification::Unavailable)
|
||||||
|
};
|
||||||
|
Ok(ToolOutput {
|
||||||
|
data,
|
||||||
|
write: Some(WriteReport {
|
||||||
|
status: WriteStatus::Completed,
|
||||||
|
verification,
|
||||||
|
target,
|
||||||
|
receipt,
|
||||||
|
}),
|
||||||
|
attachment: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
99
src/tools/validation.rs
Normal file
99
src/tools/validation.rs
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
use crate::{
|
||||||
|
error::{AppError, Result},
|
||||||
|
model::RecordId,
|
||||||
|
};
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
pub enum RecordSelector {
|
||||||
|
Id(RecordId),
|
||||||
|
ExactName(String),
|
||||||
|
}
|
||||||
|
pub struct Conversion {
|
||||||
|
pub from: RecordId,
|
||||||
|
pub to: RecordId,
|
||||||
|
pub factor: Decimal,
|
||||||
|
}
|
||||||
|
pub fn record_id(value: &Value) -> Result<RecordId> {
|
||||||
|
let id = value
|
||||||
|
.as_u64()
|
||||||
|
.or_else(|| value.as_str().and_then(|s| s.parse().ok()))
|
||||||
|
.ok_or_else(AppError::input)?;
|
||||||
|
if id == 0 {
|
||||||
|
return Err(AppError::input());
|
||||||
|
}
|
||||||
|
Ok(RecordId(id))
|
||||||
|
}
|
||||||
|
pub fn resolve_record(rows: &[Value], selector: &RecordSelector) -> Result<RecordId> {
|
||||||
|
let matches = rows
|
||||||
|
.iter()
|
||||||
|
.filter(|row| match selector {
|
||||||
|
RecordSelector::Id(id) => record_id(&row["id"]).is_ok_and(|n| n == *id),
|
||||||
|
RecordSelector::ExactName(name) => row["name"].as_str() == Some(name),
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
match matches.as_slice() {
|
||||||
|
[row] => record_id(&row["id"]),
|
||||||
|
[] => Err(AppError::new(
|
||||||
|
"NOT_FOUND",
|
||||||
|
"No exact record matches. List the relevant records first.",
|
||||||
|
)),
|
||||||
|
_ => Err(AppError {
|
||||||
|
code: "AMBIGUOUS_RECORD",
|
||||||
|
message: "Multiple records have this name. Choose a candidate ID explicitly.",
|
||||||
|
recovery: Some(
|
||||||
|
json!({"candidates":matches.iter().map(|r|json!({"id":r["id"],"name":r["name"]})).collect::<Vec<_>>()}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn convert_quantity(
|
||||||
|
amount: Decimal,
|
||||||
|
from: RecordId,
|
||||||
|
to: RecordId,
|
||||||
|
conversions: &[Conversion],
|
||||||
|
) -> Result<Decimal> {
|
||||||
|
if amount < Decimal::ZERO {
|
||||||
|
return Err(AppError::input());
|
||||||
|
}
|
||||||
|
if from == to {
|
||||||
|
return Ok(amount);
|
||||||
|
}
|
||||||
|
let matches = conversions
|
||||||
|
.iter()
|
||||||
|
.filter(|c| c.from == from && c.to == to && c.factor > Decimal::ZERO)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
match matches.as_slice() {
|
||||||
|
[c] => amount.checked_mul(c.factor).ok_or_else(AppError::input),
|
||||||
|
_ => Err(AppError::new(
|
||||||
|
"UNIT_CONVERSION_REQUIRED",
|
||||||
|
"Choose a valid unambiguous Grocy conversion to the product's stock unit.",
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_conversions(
|
||||||
|
client: &crate::client::ApiClient,
|
||||||
|
product_id: RecordId,
|
||||||
|
) -> Result<Vec<Conversion>> {
|
||||||
|
let mut r = crate::client::ApiRequest::get("/objects/{entity}");
|
||||||
|
r.params
|
||||||
|
.insert("entity".into(), "quantity_unit_conversions_resolved".into());
|
||||||
|
let data = client.request(r).await?.json()?;
|
||||||
|
let mut output = Vec::new();
|
||||||
|
for row in data.as_array().ok_or_else(AppError::input)? {
|
||||||
|
if record_id(&row["product_id"]).is_ok_and(|id| id == product_id) {
|
||||||
|
let factor = row["factor"]
|
||||||
|
.as_str()
|
||||||
|
.map(str::to_owned)
|
||||||
|
.unwrap_or_else(|| row["factor"].to_string())
|
||||||
|
.parse::<Decimal>()
|
||||||
|
.map_err(|_| AppError::input())?;
|
||||||
|
output.push(Conversion {
|
||||||
|
from: record_id(&row["from_qu_id"])?,
|
||||||
|
to: record_id(&row["to_qu_id"])?,
|
||||||
|
factor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
168
tests/catalog.rs
Normal file
168
tests/catalog.rs
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
use hermes_grocy_mcp::{
|
||||||
|
catalog,
|
||||||
|
model::{AccessMode, Page, RecordId},
|
||||||
|
tools::validation::{Conversion, RecordSelector, convert_quantity, resolve_record},
|
||||||
|
};
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
use serde_json::json;
|
||||||
|
#[test]
|
||||||
|
fn household_catalog_and_validation() {
|
||||||
|
assert!(catalog::operations(AccessMode::Full).len() > 40);
|
||||||
|
assert!(
|
||||||
|
catalog::operations(AccessMode::ReadOnly)
|
||||||
|
.iter()
|
||||||
|
.all(|o| !o.mutates)
|
||||||
|
);
|
||||||
|
for entity in ["api_keys", "sessions", "permission_hierarchy", "users"] {
|
||||||
|
assert!(
|
||||||
|
catalog::validate("records_list", json!({"entity":entity}), AccessMode::Full).is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
catalog::validate(
|
||||||
|
"records_update",
|
||||||
|
json!({"entity":"stock_log","id":1,"fields":{"amount":2}}),
|
||||||
|
AccessMode::Full
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
catalog::validate(
|
||||||
|
"records_create",
|
||||||
|
json!({"entity":"products","fields":{"name":"Sample","id":4}}),
|
||||||
|
AccessMode::Full
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
let rows = vec![
|
||||||
|
json!({"id":"1","name":"Milk"}),
|
||||||
|
json!({"id":"2","name":"Milk"}),
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
resolve_record(&rows, &RecordSelector::ExactName("Milk".into()))
|
||||||
|
.unwrap_err()
|
||||||
|
.code,
|
||||||
|
"AMBIGUOUS_RECORD"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
convert_quantity(
|
||||||
|
Decimal::from(500),
|
||||||
|
RecordId(1),
|
||||||
|
RecordId(2),
|
||||||
|
&[Conversion {
|
||||||
|
from: RecordId(1),
|
||||||
|
to: RecordId(2),
|
||||||
|
factor: "0.001".parse().unwrap()
|
||||||
|
}]
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.to_string(),
|
||||||
|
"0.500"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
convert_quantity(Decimal::ONE, RecordId(1), RecordId(2), &[])
|
||||||
|
.unwrap_err()
|
||||||
|
.code,
|
||||||
|
"UNIT_CONVERSION_REQUIRED"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
catalog::validate(
|
||||||
|
"stock_add",
|
||||||
|
json!({"product_id":1,"amount":-1}),
|
||||||
|
AccessMode::Full
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
catalog::validate(
|
||||||
|
"stock_add",
|
||||||
|
json!({"product_id":1,"amount":1,"best_before_date":"2026-02-30"}),
|
||||||
|
AccessMode::Full
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
catalog::validate(
|
||||||
|
"stock_inventory",
|
||||||
|
json!({"product_id":1,"new_amount":0}),
|
||||||
|
AccessMode::Full
|
||||||
|
)
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
assert_eq!(Page::default().limit, 100);
|
||||||
|
assert!(Page::new(0, 501).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn stock_unit_change_requires_conversion() {
|
||||||
|
use axum::{Json, Router};
|
||||||
|
use hermes_grocy_mcp::{
|
||||||
|
client::{ApiBase, ApiClient},
|
||||||
|
model::{PrivateProfile, TransportPolicy},
|
||||||
|
tools::records,
|
||||||
|
};
|
||||||
|
use std::sync::{
|
||||||
|
Arc,
|
||||||
|
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||||
|
};
|
||||||
|
let conversion = Arc::new(AtomicBool::new(false));
|
||||||
|
let writes = Arc::new(AtomicUsize::new(0));
|
||||||
|
let conv = conversion.clone();
|
||||||
|
let written = writes.clone();
|
||||||
|
let app = Router::new().fallback(move |req: axum::extract::Request| {
|
||||||
|
let conv = conv.clone();
|
||||||
|
let written = written.clone();
|
||||||
|
async move {
|
||||||
|
if req.method() == "PUT" {
|
||||||
|
written.fetch_add(1, Ordering::SeqCst);
|
||||||
|
return Json(json!({}));
|
||||||
|
}
|
||||||
|
match req.uri().path() {
|
||||||
|
"/api/objects/products/7" => Json(
|
||||||
|
json!({"id":7,"qu_id_stock":if written.load(Ordering::SeqCst)>0{2}else{1}}),
|
||||||
|
),
|
||||||
|
"/api/objects/quantity_unit_conversions_resolved" => {
|
||||||
|
Json(if conv.load(Ordering::SeqCst) {
|
||||||
|
json!([{"product_id":7,"from_qu_id":1,"to_qu_id":2,"factor":"0.001"}])
|
||||||
|
} else {
|
||||||
|
json!([])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => Json(json!({"id":2})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let port = listener.local_addr().unwrap().port();
|
||||||
|
let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
||||||
|
let client = ApiClient::new(PrivateProfile {
|
||||||
|
api_base: ApiBase::parse(
|
||||||
|
&format!("http://127.0.0.1:{port}"),
|
||||||
|
TransportPolicy::LoopbackDevelopment,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
api_key: secrecy::SecretString::from("synthetic-key"),
|
||||||
|
access: AccessMode::Full,
|
||||||
|
timezone: None,
|
||||||
|
defaults: json!(null),
|
||||||
|
ca_path: None,
|
||||||
|
transport: TransportPolicy::LoopbackDevelopment,
|
||||||
|
unsafe_storage: false,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let call = catalog::validate(
|
||||||
|
"records_update",
|
||||||
|
json!({"entity":"products","id":7,"fields":{"qu_id_stock":2}}),
|
||||||
|
AccessMode::Full,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
records::run(&client, &call).await.err().unwrap().code,
|
||||||
|
"UNIT_CONVERSION_REQUIRED"
|
||||||
|
);
|
||||||
|
assert_eq!(writes.load(Ordering::SeqCst), 0);
|
||||||
|
conversion.store(true, Ordering::SeqCst);
|
||||||
|
assert!(records::run(&client, &call).await.is_ok());
|
||||||
|
assert_eq!(writes.load(Ordering::SeqCst), 1);
|
||||||
|
server.abort();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user