feat: implement household workflows binary files and write receipts
This commit is contained in:
@@ -1137,7 +1137,9 @@
|
|||||||
"maxLength": 16384
|
"maxLength": 16384
|
||||||
},
|
},
|
||||||
"price": {
|
"price": {
|
||||||
"type": "number"
|
"type": "number",
|
||||||
|
"minimum": 0,
|
||||||
|
"allow_decimal_string": true
|
||||||
},
|
},
|
||||||
"open": {
|
"open": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
@@ -1328,7 +1330,9 @@
|
|||||||
"maxLength": 16384
|
"maxLength": 16384
|
||||||
},
|
},
|
||||||
"price": {
|
"price": {
|
||||||
"type": "number"
|
"type": "number",
|
||||||
|
"minimum": 0,
|
||||||
|
"allow_decimal_string": true
|
||||||
},
|
},
|
||||||
"location_id": {
|
"location_id": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
@@ -1442,10 +1446,12 @@
|
|||||||
"allow_decimal_string": true
|
"allow_decimal_string": true
|
||||||
},
|
},
|
||||||
"location_id_from": {
|
"location_id_from": {
|
||||||
"type": "integer"
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
},
|
},
|
||||||
"location_id_to": {
|
"location_id_to": {
|
||||||
"type": "integer"
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
},
|
},
|
||||||
"stock_entry_id": {
|
"stock_entry_id": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -1454,7 +1460,9 @@
|
|||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"product_id",
|
"product_id",
|
||||||
"amount"
|
"amount",
|
||||||
|
"location_id_from",
|
||||||
|
"location_id_to"
|
||||||
],
|
],
|
||||||
"path_params": {
|
"path_params": {
|
||||||
"product_id": "productId"
|
"product_id": "productId"
|
||||||
@@ -1492,7 +1500,9 @@
|
|||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"price": {
|
"price": {
|
||||||
"type": "number"
|
"type": "number",
|
||||||
|
"minimum": 0,
|
||||||
|
"allow_decimal_string": true
|
||||||
},
|
},
|
||||||
"stock_label_type": {
|
"stock_label_type": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
@@ -1656,7 +1666,9 @@
|
|||||||
"maxLength": 16384
|
"maxLength": 16384
|
||||||
},
|
},
|
||||||
"price": {
|
"price": {
|
||||||
"type": "number"
|
"type": "number",
|
||||||
|
"minimum": 0,
|
||||||
|
"allow_decimal_string": true
|
||||||
},
|
},
|
||||||
"location_id": {
|
"location_id": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
@@ -1754,10 +1766,12 @@
|
|||||||
"allow_decimal_string": true
|
"allow_decimal_string": true
|
||||||
},
|
},
|
||||||
"location_id_from": {
|
"location_id_from": {
|
||||||
"type": "integer"
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
},
|
},
|
||||||
"location_id_to": {
|
"location_id_to": {
|
||||||
"type": "integer"
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
},
|
},
|
||||||
"stock_entry_id": {
|
"stock_entry_id": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -1766,7 +1780,9 @@
|
|||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"barcode",
|
"barcode",
|
||||||
"amount"
|
"amount",
|
||||||
|
"location_id_from",
|
||||||
|
"location_id_to"
|
||||||
],
|
],
|
||||||
"path_params": {
|
"path_params": {
|
||||||
"barcode": "barcode"
|
"barcode": "barcode"
|
||||||
@@ -1801,7 +1817,9 @@
|
|||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"price": {
|
"price": {
|
||||||
"type": "number"
|
"type": "number",
|
||||||
|
"minimum": 0,
|
||||||
|
"allow_decimal_string": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -1886,10 +1904,13 @@
|
|||||||
"method": "post",
|
"method": "post",
|
||||||
"properties": {
|
"properties": {
|
||||||
"list_id": {
|
"list_id": {
|
||||||
"type": "integer"
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [],
|
"required": [
|
||||||
|
"list_id"
|
||||||
|
],
|
||||||
"path_params": {},
|
"path_params": {},
|
||||||
"body_keys": [
|
"body_keys": [
|
||||||
"list_id"
|
"list_id"
|
||||||
@@ -1901,10 +1922,13 @@
|
|||||||
"method": "post",
|
"method": "post",
|
||||||
"properties": {
|
"properties": {
|
||||||
"list_id": {
|
"list_id": {
|
||||||
"type": "integer"
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [],
|
"required": [
|
||||||
|
"list_id"
|
||||||
|
],
|
||||||
"path_params": {},
|
"path_params": {},
|
||||||
"body_keys": [
|
"body_keys": [
|
||||||
"list_id"
|
"list_id"
|
||||||
@@ -1916,10 +1940,13 @@
|
|||||||
"method": "post",
|
"method": "post",
|
||||||
"properties": {
|
"properties": {
|
||||||
"list_id": {
|
"list_id": {
|
||||||
"type": "integer"
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [],
|
"required": [
|
||||||
|
"list_id"
|
||||||
|
],
|
||||||
"path_params": {},
|
"path_params": {},
|
||||||
"body_keys": [
|
"body_keys": [
|
||||||
"list_id"
|
"list_id"
|
||||||
@@ -1931,13 +1958,16 @@
|
|||||||
"method": "post",
|
"method": "post",
|
||||||
"properties": {
|
"properties": {
|
||||||
"list_id": {
|
"list_id": {
|
||||||
"type": "integer"
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
},
|
},
|
||||||
"done_only": {
|
"done_only": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [],
|
"required": [
|
||||||
|
"list_id"
|
||||||
|
],
|
||||||
"path_params": {},
|
"path_params": {},
|
||||||
"body_keys": [
|
"body_keys": [
|
||||||
"list_id",
|
"list_id",
|
||||||
@@ -1950,23 +1980,29 @@
|
|||||||
"method": "post",
|
"method": "post",
|
||||||
"properties": {
|
"properties": {
|
||||||
"product_id": {
|
"product_id": {
|
||||||
"type": "integer"
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
},
|
},
|
||||||
"qu_id": {
|
"qu_id": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"list_id": {
|
"list_id": {
|
||||||
"type": "integer"
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
},
|
},
|
||||||
"product_amount": {
|
"product_amount": {
|
||||||
"type": "number"
|
"type": "number",
|
||||||
|
"minimum": 0,
|
||||||
|
"allow_decimal_string": true
|
||||||
},
|
},
|
||||||
"note": {
|
"note": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"maxLength": 16384
|
"maxLength": 16384
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [],
|
"required": [
|
||||||
|
"list_id"
|
||||||
|
],
|
||||||
"path_params": {},
|
"path_params": {},
|
||||||
"body_keys": [
|
"body_keys": [
|
||||||
"product_id",
|
"product_id",
|
||||||
@@ -1982,16 +2018,22 @@
|
|||||||
"method": "post",
|
"method": "post",
|
||||||
"properties": {
|
"properties": {
|
||||||
"product_id": {
|
"product_id": {
|
||||||
"type": "integer"
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
},
|
},
|
||||||
"list_id": {
|
"list_id": {
|
||||||
"type": "integer"
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
},
|
},
|
||||||
"product_amount": {
|
"product_amount": {
|
||||||
"type": "number"
|
"type": "number",
|
||||||
|
"minimum": 0,
|
||||||
|
"allow_decimal_string": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [],
|
"required": [
|
||||||
|
"list_id"
|
||||||
|
],
|
||||||
"path_params": {},
|
"path_params": {},
|
||||||
"body_keys": [
|
"body_keys": [
|
||||||
"product_id",
|
"product_id",
|
||||||
|
|||||||
@@ -179,13 +179,18 @@ pub fn operations(mode: AccessMode) -> Vec<OperationSpec> {
|
|||||||
body_keys,
|
body_keys,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
ops.extend(crate::tools::files::operations(mode));
|
||||||
ops
|
ops
|
||||||
}
|
}
|
||||||
pub fn validate(name: &str, args: Value, mode: AccessMode) -> Result<ValidatedCall> {
|
pub fn validate(name: &str, args: Value, mode: AccessMode) -> Result<ValidatedCall> {
|
||||||
if serde_json::to_vec(&args)
|
if serde_json::to_vec(&args)
|
||||||
.map_err(|_| AppError::input())?
|
.map_err(|_| AppError::input())?
|
||||||
.len()
|
.len()
|
||||||
> crate::client::JSON_LIMIT
|
> if name == "file_upload" {
|
||||||
|
12 * 1024 * 1024
|
||||||
|
} else {
|
||||||
|
crate::client::JSON_LIMIT
|
||||||
|
}
|
||||||
{
|
{
|
||||||
return Err(AppError::input());
|
return Err(AppError::input());
|
||||||
}
|
}
|
||||||
@@ -239,6 +244,18 @@ pub fn validate(name: &str, args: Value, mode: AccessMode) -> Result<ValidatedCa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if name.starts_with("file_") {
|
||||||
|
crate::tools::files::validate_filename(
|
||||||
|
args["filename"].as_str().ok_or_else(AppError::input)?,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
for key in op.path_params.keys() {
|
||||||
|
if let Some(s) = args[key].as_str() {
|
||||||
|
if s.contains(['/', '\\', '%']) || matches!(s, "." | "..") {
|
||||||
|
return Err(AppError::input());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let offset = args["offset"].as_u64().unwrap_or(0);
|
let offset = args["offset"].as_u64().unwrap_or(0);
|
||||||
let limit = args["limit"].as_u64().unwrap_or(100);
|
let limit = args["limit"].as_u64().unwrap_or(100);
|
||||||
let page = Page::new(
|
let page = Page::new(
|
||||||
|
|||||||
240
src/tools/actions.rs
Normal file
240
src/tools/actions.rs
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
use crate::{
|
||||||
|
catalog::ValidatedCall,
|
||||||
|
client::{ApiClient, ApiRequest, RequestBody},
|
||||||
|
error::{AppError, Result},
|
||||||
|
model::{ToolOutput, Verification, WriteReport, WriteStatus},
|
||||||
|
};
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
fn build(call: &ValidatedCall) -> ApiRequest {
|
||||||
|
let mut r = ApiRequest::get(call.op.route);
|
||||||
|
r.method = call.op.method;
|
||||||
|
for (key, param) in &call.op.path_params {
|
||||||
|
let value = &call.args[key];
|
||||||
|
r.params.insert(
|
||||||
|
param.clone(),
|
||||||
|
value
|
||||||
|
.as_str()
|
||||||
|
.map(str::to_owned)
|
||||||
|
.unwrap_or_else(|| value.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !call.op.body_keys.is_empty() {
|
||||||
|
let body = call
|
||||||
|
.op
|
||||||
|
.body_keys
|
||||||
|
.iter()
|
||||||
|
.filter_map(|k| call.args.get(k).map(|v| (k.clone(), v.clone())))
|
||||||
|
.collect::<serde_json::Map<_, _>>();
|
||||||
|
r.body = RequestBody::Json(Value::Object(body));
|
||||||
|
}
|
||||||
|
r
|
||||||
|
}
|
||||||
|
fn readback(route: &'static str, param: &str, id: &Value) -> ApiRequest {
|
||||||
|
let mut r = ApiRequest::get(route);
|
||||||
|
r.params.insert(
|
||||||
|
param.into(),
|
||||||
|
id.as_str()
|
||||||
|
.map(str::to_owned)
|
||||||
|
.unwrap_or_else(|| id.to_string()),
|
||||||
|
);
|
||||||
|
r
|
||||||
|
}
|
||||||
|
pub async fn run(client: &ApiClient, call: &ValidatedCall) -> Result<ToolOutput> {
|
||||||
|
let mut r = build(call);
|
||||||
|
let target = r.target();
|
||||||
|
let mut verify = None;
|
||||||
|
if call.op.mutates {
|
||||||
|
if let Some(id) = call
|
||||||
|
.args
|
||||||
|
.get("product_id")
|
||||||
|
.or_else(|| call.args.get("product_id_to_keep"))
|
||||||
|
{
|
||||||
|
let read = readback("/stock/products/{productId}", "productId", id);
|
||||||
|
let details = client.request(read).await?.json()?;
|
||||||
|
if let Some(unit) = call.args.get("quantity_unit_id") {
|
||||||
|
apply_conversion(client, call, &details, &mut r, unit).await?;
|
||||||
|
}
|
||||||
|
verify = Some(readback("/stock/products/{productId}", "productId", id));
|
||||||
|
} else if let Some(barcode) = call.args.get("barcode") {
|
||||||
|
let read = readback("/stock/products/by-barcode/{barcode}", "barcode", barcode);
|
||||||
|
let details = client.request(read).await?.json()?;
|
||||||
|
if let Some(unit) = call.args.get("quantity_unit_id") {
|
||||||
|
apply_conversion(client, call, &details, &mut r, unit).await?;
|
||||||
|
}
|
||||||
|
verify = Some(readback(
|
||||||
|
"/stock/products/by-barcode/{barcode}",
|
||||||
|
"barcode",
|
||||||
|
barcode,
|
||||||
|
));
|
||||||
|
} else if let Some(id) = call.args.get("entry_id") {
|
||||||
|
let details = client
|
||||||
|
.request(readback("/stock/entry/{entryId}", "entryId", id))
|
||||||
|
.await?
|
||||||
|
.json()?;
|
||||||
|
if let Some(unit) = call.args.get("quantity_unit_id") {
|
||||||
|
let details = json!({"product":super::records::get_record(client,"products",&details["product_id"]).await?});
|
||||||
|
apply_conversion(client, call, &details, &mut r, unit).await?;
|
||||||
|
}
|
||||||
|
verify = Some(readback("/stock/entry/{entryId}", "entryId", id));
|
||||||
|
} else if let Some(id) = call.args.get("recipe_id") {
|
||||||
|
super::records::get_record(client, "recipes", id).await?;
|
||||||
|
verify = Some(readback("/recipes/{recipeId}/fulfillment", "recipeId", id));
|
||||||
|
} else if let Some(id) = call
|
||||||
|
.args
|
||||||
|
.get("chore_id")
|
||||||
|
.or_else(|| call.args.get("chore_id_to_keep"))
|
||||||
|
{
|
||||||
|
super::records::get_record(client, "chores", id).await?;
|
||||||
|
verify = Some(readback("/chores/{choreId}", "choreId", id));
|
||||||
|
} else if let Some(id) = call.args.get("battery_id") {
|
||||||
|
super::records::get_record(client, "batteries", id).await?;
|
||||||
|
verify = Some(readback("/batteries/{batteryId}", "batteryId", id));
|
||||||
|
} else if let Some(id) = call.args.get("task_id") {
|
||||||
|
super::records::get_record(client, "tasks", id).await?;
|
||||||
|
verify = Some(readback("/objects/{entity}/{id}", "id", id));
|
||||||
|
verify
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.params
|
||||||
|
.insert("entity".into(), "tasks".into());
|
||||||
|
} else if let Some(id) = call.args.get("transaction_id") {
|
||||||
|
client
|
||||||
|
.request(readback(
|
||||||
|
"/stock/transactions/{transactionId}",
|
||||||
|
"transactionId",
|
||||||
|
id,
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
verify = Some(readback(
|
||||||
|
"/stock/transactions/{transactionId}",
|
||||||
|
"transactionId",
|
||||||
|
id,
|
||||||
|
));
|
||||||
|
} else if let Some(id) = call.args.get("booking_id") {
|
||||||
|
client
|
||||||
|
.request(readback("/stock/bookings/{bookingId}", "bookingId", id))
|
||||||
|
.await?;
|
||||||
|
verify = Some(readback("/stock/bookings/{bookingId}", "bookingId", id));
|
||||||
|
} else if let Some(id) = call.args.get("execution_id") {
|
||||||
|
let log = super::records::get_record(client, "chores_log", id).await?;
|
||||||
|
verify = Some(readback("/chores/{choreId}", "choreId", &log["chore_id"]));
|
||||||
|
} else if let Some(id) = call.args.get("charge_cycle_id") {
|
||||||
|
let log = super::records::get_record(client, "battery_charge_cycles", id).await?;
|
||||||
|
verify = Some(readback(
|
||||||
|
"/batteries/{batteryId}",
|
||||||
|
"batteryId",
|
||||||
|
&log["battery_id"],
|
||||||
|
));
|
||||||
|
} else if call.op.name.starts_with("shopping_") {
|
||||||
|
if let Some(id) = call.args.get("list_id") {
|
||||||
|
super::records::get_record(client, "shopping_lists", id).await?;
|
||||||
|
}
|
||||||
|
let mut q = ApiRequest::get("/objects/{entity}");
|
||||||
|
q.params.insert("entity".into(), "shopping_list".into());
|
||||||
|
verify = Some(q);
|
||||||
|
}
|
||||||
|
for key in ["location_id", "location_id_from", "location_id_to"] {
|
||||||
|
if let Some(id) = call.args.get(key) {
|
||||||
|
super::records::get_record(client, "locations", id).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if call.op.name.starts_with("stock_add") || call.op.name.starts_with("stock_inventory") {
|
||||||
|
if let RequestBody::Json(body) = &mut r.body {
|
||||||
|
body["stock_label_type"] = json!(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if call.op.mutates && call.op.name.starts_with("shopping_") {
|
||||||
|
let mut q = ApiRequest::get("/objects/{entity}");
|
||||||
|
q.params.insert("entity".into(), "shopping_list".into());
|
||||||
|
verify = Some(q);
|
||||||
|
}
|
||||||
|
if call.op.mutates && call.op.name == "chores_recalculate_assignments" {
|
||||||
|
verify = Some(ApiRequest::get("/chores"));
|
||||||
|
}
|
||||||
|
let receipt = client.request(r).await?.json()?;
|
||||||
|
if !call.op.mutates {
|
||||||
|
let data = if let Some(arr) = receipt.as_array() {
|
||||||
|
call.page.slice(arr.clone())
|
||||||
|
} else if call.op.name == "stock_volatile" {
|
||||||
|
let mut grouped = serde_json::Map::new();
|
||||||
|
for (k, v) in receipt.as_object().ok_or_else(AppError::input)? {
|
||||||
|
grouped.insert(
|
||||||
|
k.clone(),
|
||||||
|
if let Some(a) = v.as_array() {
|
||||||
|
call.page.slice(a.clone())
|
||||||
|
} else {
|
||||||
|
v.clone()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Value::Object(grouped)
|
||||||
|
} else {
|
||||||
|
receipt
|
||||||
|
};
|
||||||
|
return Ok(ToolOutput::data(data));
|
||||||
|
}
|
||||||
|
if call.op.name == "stock_copy" {
|
||||||
|
if let Some(id) = receipt.get("created_object_id") {
|
||||||
|
verify = Some(readback("/stock/products/{productId}", "productId", id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if call.op.name == "recipe_copy" {
|
||||||
|
if let Some(id) = receipt.get("created_object_id") {
|
||||||
|
verify = Some(readback("/recipes/{recipeId}/fulfillment", "recipeId", id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (data, verification) = if let Some(q) = verify {
|
||||||
|
match client.request(q).await.and_then(|r| r.json()) {
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
async fn apply_conversion(
|
||||||
|
client: &ApiClient,
|
||||||
|
call: &ValidatedCall,
|
||||||
|
details: &Value,
|
||||||
|
r: &mut ApiRequest,
|
||||||
|
unit: &Value,
|
||||||
|
) -> Result<()> {
|
||||||
|
let product = details.get("product").unwrap_or(details);
|
||||||
|
let id = super::validation::record_id(&product["id"])?;
|
||||||
|
let stock = super::validation::record_id(&product["qu_id_stock"])?;
|
||||||
|
let from = super::validation::record_id(unit)?;
|
||||||
|
let conversions = if from != stock {
|
||||||
|
super::validation::load_conversions(client, id).await?
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
};
|
||||||
|
let field = if call.args.get("new_amount").is_some() {
|
||||||
|
"new_amount"
|
||||||
|
} else {
|
||||||
|
"amount"
|
||||||
|
};
|
||||||
|
let value = &call.args[field];
|
||||||
|
let amount = value
|
||||||
|
.as_str()
|
||||||
|
.map(str::to_owned)
|
||||||
|
.unwrap_or_else(|| value.to_string())
|
||||||
|
.parse::<Decimal>()
|
||||||
|
.map_err(|_| AppError::input())?;
|
||||||
|
let converted = super::validation::convert_quantity(amount, from, stock, &conversions)?;
|
||||||
|
if let RequestBody::Json(body) = &mut r.body {
|
||||||
|
body[field] = json!(converted.to_string());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
46
src/tools/exports.rs
Normal file
46
src/tools/exports.rs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
use crate::{
|
||||||
|
catalog::ValidatedCall,
|
||||||
|
client::{ApiClient, ApiRequest, ResponseBody, ResponseKind},
|
||||||
|
error::{AppError, Result},
|
||||||
|
model::ToolOutput,
|
||||||
|
};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
pub async fn run(client: &ApiClient, call: &ValidatedCall) -> Result<ToolOutput> {
|
||||||
|
let mut r = ApiRequest::get(call.op.route);
|
||||||
|
if call.op.name == "calendar_export" {
|
||||||
|
r.kind = ResponseKind::Text;
|
||||||
|
let response = client.request(r).await?;
|
||||||
|
let ResponseBody::Text(content) = response.body else {
|
||||||
|
return Err(AppError::input());
|
||||||
|
};
|
||||||
|
if !content.trim_start().starts_with("BEGIN:VCALENDAR") {
|
||||||
|
return Err(AppError::new(
|
||||||
|
"INVALID_RESPONSE",
|
||||||
|
"Expected iCalendar data from the API.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return Ok(ToolOutput::data(
|
||||||
|
json!({"mime":"text/calendar","content":content}),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if call.op.name == "shopping_export" {
|
||||||
|
r.params.insert("entity".into(), "shopping_list".into());
|
||||||
|
let rows = client.request(r).await?.json()?;
|
||||||
|
let id = call.args["list_id"].as_u64().ok_or_else(AppError::input)?;
|
||||||
|
let items = rows
|
||||||
|
.as_array()
|
||||||
|
.ok_or_else(AppError::input)?
|
||||||
|
.iter()
|
||||||
|
.filter(|r| {
|
||||||
|
super::validation::record_id(&r["shopping_list_id"]).is_ok_and(|n| n.0 == id)
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
return Ok(ToolOutput::data(
|
||||||
|
json!({"shopping_list_id":id,"items":items}),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let rows = client.request(r).await?.json()?;
|
||||||
|
let users=rows.as_array().ok_or_else(AppError::input)?.iter().map(|u|json!({"id":u["id"],"name":u.get("display_name").or_else(||u.get("username")).unwrap_or(&Value::Null)})).collect::<Vec<_>>();
|
||||||
|
Ok(ToolOutput::data(json!({"users":users})))
|
||||||
|
}
|
||||||
167
src/tools/files.rs
Normal file
167
src/tools/files.rs
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
use crate::{
|
||||||
|
catalog::OperationSpec,
|
||||||
|
client::{
|
||||||
|
ApiClient, ApiRequest, BINARY_LIMIT, HttpMethod, RequestBody, ResponseBody, ResponseKind,
|
||||||
|
},
|
||||||
|
error::{AppError, Result},
|
||||||
|
model::{AccessMode, Attachment, ToolOutput, Verification, WriteReport, WriteStatus},
|
||||||
|
};
|
||||||
|
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
pub fn validate_filename(name: &str) -> Result<()> {
|
||||||
|
if name.is_empty()
|
||||||
|
|| name.len() > 255
|
||||||
|
|| matches!(name, "." | "..")
|
||||||
|
|| name.contains(['/', '\\', '%'])
|
||||||
|
|| name.chars().any(char::is_control)
|
||||||
|
{
|
||||||
|
return Err(AppError::input());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
pub fn operations(mode: AccessMode) -> Vec<OperationSpec> {
|
||||||
|
let mut ops = vec![];
|
||||||
|
for (name, method, route) in [
|
||||||
|
("file_get", HttpMethod::Get, "/files/{group}/{fileName}"),
|
||||||
|
("file_upload", HttpMethod::Put, "/files/{group}/{fileName}"),
|
||||||
|
(
|
||||||
|
"file_delete",
|
||||||
|
HttpMethod::Delete,
|
||||||
|
"/files/{group}/{fileName}",
|
||||||
|
),
|
||||||
|
("calendar_export", HttpMethod::Get, "/calendar/ical"),
|
||||||
|
("shopping_export", HttpMethod::Get, "/objects/{entity}"),
|
||||||
|
("assignment_users", HttpMethod::Get, "/users"),
|
||||||
|
] {
|
||||||
|
if method.mutates() && mode == AccessMode::ReadOnly {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut properties = json!({});
|
||||||
|
let mut required = vec![];
|
||||||
|
if name.starts_with("file_") {
|
||||||
|
properties = json!({"group":{"type":"string","enum":["equipmentmanuals","recipepictures","productpictures","userfiles"]},"filename":{"type":"string","maxLength":255}});
|
||||||
|
required.extend(["group", "filename"]);
|
||||||
|
if name == "file_upload" {
|
||||||
|
properties["content_base64"] = json!({"type":"string","maxLength":((BINARY_LIMIT+2)/3)*4,"description":"Standard base64 of actual file bytes, at most 8 MiB decoded. No local path or external URL."});
|
||||||
|
required.push("content_base64")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if name == "shopping_export" {
|
||||||
|
properties["list_id"] = json!({"type":"integer","minimum":1});
|
||||||
|
required.push("list_id")
|
||||||
|
}
|
||||||
|
let description = match name {
|
||||||
|
"file_get" => {
|
||||||
|
"Read a known API file by group and filename. Returns real bytes as an image or embedded resource, at most 8 MiB. Does not fetch URLs or host files. Grocy has no orphan-file listing API."
|
||||||
|
}
|
||||||
|
"file_upload" => {
|
||||||
|
"Upload bounded base64 content as binary through the API, replacing this exact named household file. Returns a write receipt and readback verification."
|
||||||
|
}
|
||||||
|
"file_delete" => {
|
||||||
|
"Permanently delete the exact API file. Specify the group and filename; no host filesystem access."
|
||||||
|
}
|
||||||
|
"calendar_export" => {
|
||||||
|
"Export household dates as iCalendar data through the API; no public sharing link."
|
||||||
|
}
|
||||||
|
"shopping_export" => {
|
||||||
|
"Export actual shopping-list items for an explicit list ID as JSON; no printer action."
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
"List assignment user IDs and display names only. No passwords, keys or account fields."
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ops.push(OperationSpec{name,route,method,schema:json!({"type":"object","properties":properties,"required":required,"additionalProperties":false}),mutates:method.mutates(),destructive:method.mutates(),description:description.into(),path_params:Default::default(),body_keys:vec![]});
|
||||||
|
}
|
||||||
|
ops
|
||||||
|
}
|
||||||
|
fn file_request(args: &Value, method: HttpMethod) -> Result<ApiRequest> {
|
||||||
|
let filename = args["filename"].as_str().ok_or_else(AppError::input)?;
|
||||||
|
validate_filename(filename)?;
|
||||||
|
let mut r = ApiRequest::get("/files/{group}/{fileName}");
|
||||||
|
r.params.insert(
|
||||||
|
"group".into(),
|
||||||
|
args["group"].as_str().ok_or_else(AppError::input)?.into(),
|
||||||
|
);
|
||||||
|
r.params
|
||||||
|
.insert("fileName".into(), STANDARD.encode(filename));
|
||||||
|
r.method = method;
|
||||||
|
r.kind = ResponseKind::Binary;
|
||||||
|
Ok(r)
|
||||||
|
}
|
||||||
|
pub async fn run(client: &ApiClient, call: &crate::catalog::ValidatedCall) -> Result<ToolOutput> {
|
||||||
|
let mut r = file_request(&call.args, call.op.method)?;
|
||||||
|
let target = json!({"group":call.args["group"],"filename":call.args["filename"]});
|
||||||
|
let mut expected = None;
|
||||||
|
if call.op.method == HttpMethod::Put {
|
||||||
|
let encoded = call.args["content_base64"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(AppError::input)?;
|
||||||
|
if encoded.len() > ((BINARY_LIMIT + 2) / 3) * 4 {
|
||||||
|
return Err(AppError::input());
|
||||||
|
}
|
||||||
|
let bytes = STANDARD.decode(encoded).map_err(|_| AppError::input())?;
|
||||||
|
if bytes.len() > BINARY_LIMIT {
|
||||||
|
return Err(AppError::input());
|
||||||
|
}
|
||||||
|
expected = Some(bytes.clone());
|
||||||
|
r.body = RequestBody::Binary(bytes);
|
||||||
|
}
|
||||||
|
let response = client.request(r).await?;
|
||||||
|
if call.op.method == HttpMethod::Get {
|
||||||
|
let ResponseBody::Binary(bytes) = response.body else {
|
||||||
|
return Err(AppError::input());
|
||||||
|
};
|
||||||
|
let mime = validated_mime(&bytes, &response.content_type);
|
||||||
|
return Ok(ToolOutput {
|
||||||
|
data: json!({"file":target,"bytes":bytes.len(),"mime":mime}),
|
||||||
|
write: None,
|
||||||
|
attachment: Some(Attachment {
|
||||||
|
bytes,
|
||||||
|
mime,
|
||||||
|
name: call.args["filename"].as_str().unwrap().into(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let check = client
|
||||||
|
.request(file_request(&call.args, HttpMethod::Get)?)
|
||||||
|
.await;
|
||||||
|
let verification = match (call.op.method, check) {
|
||||||
|
(HttpMethod::Delete, Err(e)) if e.code == "NOT_FOUND" => Verification::Confirmed,
|
||||||
|
(HttpMethod::Put, Ok(r)) => match r.body {
|
||||||
|
ResponseBody::Binary(b) if Some(&b) == expected.as_ref() => Verification::Confirmed,
|
||||||
|
_ => Verification::Incomplete,
|
||||||
|
},
|
||||||
|
_ => Verification::Incomplete,
|
||||||
|
};
|
||||||
|
Ok(ToolOutput {
|
||||||
|
data: json!({"file":target,"bytes":expected.as_ref().map(Vec::len)}),
|
||||||
|
write: Some(WriteReport {
|
||||||
|
status: WriteStatus::Completed,
|
||||||
|
verification,
|
||||||
|
target,
|
||||||
|
receipt: json!({"http_status":response.status}),
|
||||||
|
}),
|
||||||
|
attachment: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn validated_mime(bytes: &[u8], reported: &str) -> String {
|
||||||
|
let image = if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||||||
|
Some("image/png")
|
||||||
|
} else if bytes.starts_with(&[255, 216, 255]) {
|
||||||
|
Some("image/jpeg")
|
||||||
|
} else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
|
||||||
|
Some("image/gif")
|
||||||
|
} else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") {
|
||||||
|
Some("image/webp")
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if let Some(mime) = image {
|
||||||
|
return mime.into();
|
||||||
|
}
|
||||||
|
if reported == "application/pdf" && bytes.starts_with(b"%PDF-") {
|
||||||
|
"application/pdf".into()
|
||||||
|
} else {
|
||||||
|
"application/octet-stream".into()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,2 +1,17 @@
|
|||||||
|
mod actions;
|
||||||
|
pub mod exports;
|
||||||
|
pub mod files;
|
||||||
pub mod records;
|
pub mod records;
|
||||||
pub mod validation;
|
pub mod validation;
|
||||||
|
use crate::{catalog::ValidatedCall, client::ApiClient, error::Result, model::ToolOutput};
|
||||||
|
pub async fn execute(client: &ApiClient, call: ValidatedCall) -> Result<ToolOutput> {
|
||||||
|
if call.op.name.starts_with("records_") || call.op.name.starts_with("userfields_") {
|
||||||
|
records::run(client, &call).await
|
||||||
|
} else if call.op.name.starts_with("file_") {
|
||||||
|
files::run(client, &call).await
|
||||||
|
} else if ["calendar_export", "shopping_export", "assignment_users"].contains(&call.op.name) {
|
||||||
|
exports::run(client, &call).await
|
||||||
|
} else {
|
||||||
|
actions::run(client, &call).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
135
tests/workflows.rs
Normal file
135
tests/workflows.rs
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
use axum::{Router, http::StatusCode, response::IntoResponse};
|
||||||
|
use hermes_grocy_mcp::{
|
||||||
|
catalog,
|
||||||
|
client::{ApiBase, ApiClient},
|
||||||
|
model::{AccessMode, PrivateProfile, TransportPolicy, Verification, WriteStatus},
|
||||||
|
tools,
|
||||||
|
};
|
||||||
|
use serde_json::json;
|
||||||
|
use std::sync::{
|
||||||
|
Arc,
|
||||||
|
atomic::{AtomicUsize, Ordering},
|
||||||
|
};
|
||||||
|
fn profile(url: &str) -> PrivateProfile {
|
||||||
|
PrivateProfile {
|
||||||
|
api_base: ApiBase::parse(url, 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn workflow_results_and_files() {
|
||||||
|
let writes = Arc::new(AtomicUsize::new(0));
|
||||||
|
let accepted = writes.clone();
|
||||||
|
let uploaded = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||||
|
let captured = uploaded.clone();
|
||||||
|
let app = Router::new().fallback(move |req: axum::extract::Request| {
|
||||||
|
let accepted = accepted.clone();
|
||||||
|
let captured = captured.clone();
|
||||||
|
async move {
|
||||||
|
let path = req.uri().path().to_string();
|
||||||
|
let method = req.method().to_string();
|
||||||
|
if path.starts_with("/api/files/") {
|
||||||
|
if method == "PUT" {
|
||||||
|
*captured.lock().unwrap() =
|
||||||
|
axum::body::to_bytes(req.into_body(), 8 * 1024 * 1024)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.to_vec();
|
||||||
|
return StatusCode::NO_CONTENT.into_response();
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
[("content-type", "application/pdf")],
|
||||||
|
b"%PDF-synthetic".to_vec(),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
if path == "/api/calendar/ical" {
|
||||||
|
return (
|
||||||
|
[("content-type", "text/calendar")],
|
||||||
|
"BEGIN:VCALENDAR\r\nEND:VCALENDAR",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
if method == "POST" {
|
||||||
|
accepted.fetch_add(1, Ordering::SeqCst);
|
||||||
|
return (
|
||||||
|
[("content-type", "application/json")],
|
||||||
|
"[{\"transaction_id\":\"synthetic-transaction\"}]",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
if path == "/api/stock/products/7" {
|
||||||
|
if accepted.load(Ordering::SeqCst) > 0 {
|
||||||
|
return StatusCode::SERVICE_UNAVAILABLE.into_response();
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
[("content-type", "application/json")],
|
||||||
|
"{\"product\":{\"id\":7,\"qu_id_stock\":1}}",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
if path == "/api/stock/volatile" {
|
||||||
|
return axum::Json(json!({"due_products":[{"id":7}],"overdue_products":[]}))
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
axum::Json(json!([])).into_response()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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(profile(&format!("http://127.0.0.1:{port}"))).unwrap();
|
||||||
|
let call = |name, args| catalog::validate(name, args, AccessMode::Full).unwrap();
|
||||||
|
let volatile = tools::execute(&client, call("stock_volatile", json!({})))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(volatile.data["due_products"]["items"][0]["id"], 7);
|
||||||
|
let written = tools::execute(
|
||||||
|
&client,
|
||||||
|
call("stock_add", json!({"product_id":7,"amount":1})),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let report = written.write.unwrap();
|
||||||
|
assert_eq!(report.status, WriteStatus::Completed);
|
||||||
|
assert_eq!(report.verification, Verification::Incomplete);
|
||||||
|
assert_eq!(report.receipt[0]["transaction_id"], "synthetic-transaction");
|
||||||
|
assert_eq!(writes.load(Ordering::SeqCst), 1);
|
||||||
|
let upload=tools::execute(&client,call("file_upload",json!({"group":"userfiles","filename":"sample.pdf","content_base64":"JVBERi1zeW50aGV0aWM="}))).await.unwrap();
|
||||||
|
assert_eq!(*uploaded.lock().unwrap(), b"%PDF-synthetic");
|
||||||
|
assert_eq!(upload.write.unwrap().status, WriteStatus::Completed);
|
||||||
|
let file = tools::execute(
|
||||||
|
&client,
|
||||||
|
call(
|
||||||
|
"file_get",
|
||||||
|
json!({"group":"userfiles","filename":"sample.pdf"}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(file.attachment.unwrap().bytes, b"%PDF-synthetic");
|
||||||
|
assert!(
|
||||||
|
catalog::validate(
|
||||||
|
"file_get",
|
||||||
|
json!({"group":"userfiles","filename":"../unsafe"}),
|
||||||
|
AccessMode::Full
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
let ical = tools::execute(&client, call("calendar_export", json!({})))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
ical.data["content"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.starts_with("BEGIN:VCALENDAR")
|
||||||
|
);
|
||||||
|
server.abort();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user