diff --git a/docs/api-coverage.md b/docs/api-coverage.md new file mode 100644 index 0000000..bc74832 --- /dev/null +++ b/docs/api-coverage.md @@ -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). diff --git a/src/catalog/data.json b/src/catalog/data.json new file mode 100644 index 0000000..044dffb --- /dev/null +++ b/src/catalog/data.json @@ -0,0 +1,2489 @@ +{ + "version": "4.7.1", + "entities": { + "products": { + "writable": true, + "fields": { + "name": { + "type": "string", + "maxLength": 16384 + }, + "description": { + "type": "string", + "nullable": true, + "maxLength": 16384 + }, + "product_group_id": { + "type": "integer", + "nullable": true + }, + "active": { + "type": "integer" + }, + "location_id": { + "type": "integer" + }, + "shopping_location_id": { + "type": "integer", + "nullable": true + }, + "qu_id_purchase": { + "type": "integer" + }, + "qu_id_stock": { + "type": "integer" + }, + "min_stock_amount": { + "type": "integer" + }, + "default_best_before_days": { + "type": "integer" + }, + "default_best_before_days_after_open": { + "type": "integer" + }, + "default_best_before_days_after_freezing": { + "type": "integer" + }, + "default_best_before_days_after_thawing": { + "type": "integer" + }, + "picture_file_name": { + "type": "string", + "nullable": true, + "maxLength": 16384 + }, + "enable_tare_weight_handling": { + "type": "integer" + }, + "tare_weight": { + "type": "number" + }, + "not_check_stock_fulfillment_for_recipes": { + "type": "integer", + "nullable": true + }, + "parent_product_id": { + "type": "integer", + "nullable": true + }, + "calories": { + "type": "integer", + "nullable": true + }, + "cumulate_min_stock_amount_of_sub_products": { + "type": "integer", + "nullable": true + }, + "due_type": { + "type": "integer" + }, + "quick_consume_amount": { + "type": "number" + }, + "hide_on_stock_overview": { + "type": "integer" + }, + "default_stock_label_type": { + "type": "integer" + }, + "should_not_be_frozen": { + "type": "integer" + }, + "treat_opened_as_out_of_stock": { + "type": "integer" + }, + "no_own_stock": { + "type": "integer" + }, + "default_consume_location_id": { + "type": "integer", + "nullable": true + }, + "move_on_open": { + "type": "integer" + }, + "qu_id_consume": { + "type": "integer", + "nullable": true + }, + "auto_reprint_stock_label": { + "type": "integer" + }, + "quick_open_amount": { + "type": "number" + }, + "qu_id_price": { + "type": "integer", + "nullable": true + }, + "disable_open": { + "type": "integer" + }, + "default_purchase_price_type": { + "type": "integer" + } + }, + "required": [ + "name", + "location_id", + "qu_id_purchase", + "qu_id_stock" + ] + }, + "chores": { + "writable": true, + "fields": { + "name": { + "type": "string", + "maxLength": 16384 + }, + "description": { + "type": "string", + "nullable": true, + "maxLength": 16384 + }, + "period_type": { + "type": "string", + "maxLength": 16384 + }, + "period_days": { + "type": "integer", + "nullable": true + }, + "period_config": { + "type": "string", + "nullable": true, + "maxLength": 16384 + }, + "track_date_only": { + "type": "integer", + "nullable": true + }, + "rollover": { + "type": "integer", + "nullable": true + }, + "assignment_type": { + "type": "string", + "nullable": true, + "maxLength": 16384 + }, + "assignment_config": { + "type": "string", + "nullable": true, + "maxLength": 16384 + }, + "next_execution_assigned_to_user_id": { + "type": "integer", + "nullable": true + }, + "consume_product_on_execution": { + "type": "integer" + }, + "product_id": { + "type": "integer", + "nullable": true + }, + "product_amount": { + "type": "number", + "nullable": true + }, + "period_interval": { + "type": "integer" + }, + "active": { + "type": "integer" + }, + "start_date": { + "type": "string", + "nullable": true, + "maxLength": 16384 + }, + "rescheduled_date": { + "type": "string", + "nullable": true, + "maxLength": 16384 + }, + "rescheduled_next_execution_assigned_to_user_id": { + "type": "integer", + "nullable": true + } + }, + "required": [ + "name", + "period_type" + ] + }, + "product_barcodes": { + "writable": true, + "fields": { + "product_id": { + "type": "integer" + }, + "barcode": { + "type": "string", + "maxLength": 16384 + }, + "qu_id": { + "type": "integer", + "nullable": true + }, + "amount": { + "type": "number", + "nullable": true + }, + "shopping_location_id": { + "type": "integer", + "nullable": true + }, + "last_price": { + "type": "number", + "nullable": true + }, + "note": { + "type": "string", + "nullable": true, + "maxLength": 16384 + } + }, + "required": [ + "product_id", + "barcode" + ] + }, + "batteries": { + "writable": true, + "fields": { + "name": { + "type": "string", + "maxLength": 16384 + }, + "description": { + "type": "string", + "nullable": true, + "maxLength": 16384 + }, + "used_in": { + "type": "string", + "nullable": true, + "maxLength": 16384 + }, + "charge_interval_days": { + "type": "integer" + }, + "active": { + "type": "integer" + } + }, + "required": [ + "name" + ] + }, + "locations": { + "writable": true, + "fields": { + "name": { + "type": "string", + "maxLength": 16384 + }, + "description": { + "type": "string", + "nullable": true, + "maxLength": 16384 + }, + "is_freezer": { + "type": "integer" + }, + "active": { + "type": "integer" + } + }, + "required": [ + "name" + ] + }, + "quantity_units": { + "writable": true, + "fields": { + "name": { + "type": "string", + "maxLength": 16384 + }, + "description": { + "type": "string", + "nullable": true, + "maxLength": 16384 + }, + "name_plural": { + "type": "string", + "nullable": true, + "maxLength": 16384 + }, + "plural_forms": { + "type": "string", + "nullable": true, + "maxLength": 16384 + }, + "active": { + "type": "integer" + } + }, + "required": [ + "name" + ] + }, + "quantity_unit_conversions": { + "writable": true, + "fields": { + "from_qu_id": { + "type": "integer" + }, + "to_qu_id": { + "type": "integer" + }, + "factor": { + "type": "number" + }, + "product_id": { + "type": "integer", + "nullable": true + } + }, + "required": [ + "from_qu_id", + "to_qu_id", + "factor" + ] + }, + "shopping_list": { + "writable": true, + "fields": { + "product_id": { + "type": "integer", + "nullable": true + }, + "note": { + "type": "string", + "nullable": true, + "maxLength": 16384 + }, + "amount": { + "type": "number" + }, + "shopping_list_id": { + "type": "integer", + "nullable": true + }, + "done": { + "type": "integer", + "nullable": true + }, + "qu_id": { + "type": "integer", + "nullable": true + } + }, + "required": [] + }, + "shopping_lists": { + "writable": true, + "fields": { + "name": { + "type": "string", + "maxLength": 16384 + }, + "description": { + "type": "string", + "nullable": true, + "maxLength": 16384 + } + }, + "required": [ + "name" + ] + }, + "shopping_locations": { + "writable": true, + "fields": { + "name": { + "type": "string", + "maxLength": 16384 + }, + "description": { + "type": "string", + "nullable": true, + "maxLength": 16384 + }, + "active": { + "type": "integer" + } + }, + "required": [ + "name" + ] + }, + "recipes": { + "writable": true, + "fields": { + "name": { + "type": "string", + "maxLength": 16384 + }, + "description": { + "type": "string", + "nullable": true, + "maxLength": 16384 + }, + "picture_file_name": { + "type": "string", + "nullable": true, + "maxLength": 16384 + }, + "base_servings": { + "type": "integer", + "nullable": true + }, + "desired_servings": { + "type": "integer", + "nullable": true + }, + "not_check_shoppinglist": { + "type": "integer" + }, + "type": { + "type": "string", + "nullable": true, + "maxLength": 16384 + }, + "product_id": { + "type": "integer", + "nullable": true + } + }, + "required": [ + "name" + ] + }, + "recipes_pos": { + "writable": true, + "fields": { + "recipe_id": { + "type": "integer" + }, + "product_id": { + "type": "integer" + }, + "amount": { + "type": "number" + }, + "note": { + "type": "string", + "nullable": true, + "maxLength": 16384 + }, + "qu_id": { + "type": "integer", + "nullable": true + }, + "only_check_single_unit_in_stock": { + "type": "integer" + }, + "ingredient_group": { + "type": "string", + "nullable": true, + "maxLength": 16384 + }, + "not_check_stock_fulfillment": { + "type": "integer" + }, + "variable_amount": { + "type": "string", + "nullable": true, + "maxLength": 16384 + }, + "price_factor": { + "type": "number" + }, + "round_up": { + "type": "integer" + } + }, + "required": [ + "recipe_id", + "product_id" + ] + }, + "recipes_nestings": { + "writable": true, + "fields": { + "recipe_id": { + "type": "integer" + }, + "includes_recipe_id": { + "type": "integer" + }, + "servings": { + "type": "integer", + "nullable": true + } + }, + "required": [ + "recipe_id", + "includes_recipe_id" + ] + }, + "tasks": { + "writable": true, + "fields": { + "name": { + "type": "string", + "maxLength": 16384 + }, + "description": { + "type": "string", + "nullable": true, + "maxLength": 16384 + }, + "due_date": { + "type": "string", + "nullable": true, + "maxLength": 16384 + }, + "done": { + "type": "integer" + }, + "done_timestamp": { + "type": "string", + "nullable": true, + "maxLength": 16384 + }, + "category_id": { + "type": "integer", + "nullable": true + }, + "assigned_to_user_id": { + "type": "integer", + "nullable": true + } + }, + "required": [ + "name" + ] + }, + "task_categories": { + "writable": true, + "fields": { + "name": { + "type": "string", + "maxLength": 16384 + }, + "description": { + "type": "string", + "nullable": true, + "maxLength": 16384 + }, + "active": { + "type": "integer" + } + }, + "required": [ + "name" + ] + }, + "product_groups": { + "writable": true, + "fields": { + "name": { + "type": "string", + "maxLength": 16384 + }, + "description": { + "type": "string", + "nullable": true, + "maxLength": 16384 + }, + "active": { + "type": "integer" + } + }, + "required": [ + "name" + ] + }, + "equipment": { + "writable": true, + "fields": { + "name": { + "type": "string", + "maxLength": 16384 + }, + "description": { + "type": "string", + "nullable": true, + "maxLength": 16384 + }, + "instruction_manual_file_name": { + "type": "string", + "nullable": true, + "maxLength": 16384 + } + }, + "required": [ + "name" + ] + }, + "userfields": { + "writable": true, + "fields": { + "entity": { + "type": "string", + "maxLength": 16384 + }, + "name": { + "type": "string", + "maxLength": 16384 + }, + "caption": { + "type": "string", + "maxLength": 16384 + }, + "type": { + "type": "string", + "maxLength": 16384 + }, + "show_as_column_in_tables": { + "type": "integer" + }, + "config": { + "type": "string", + "nullable": true, + "maxLength": 16384 + }, + "sort_number": { + "type": "integer", + "nullable": true + }, + "input_required": { + "type": "integer" + }, + "default_value": { + "type": "string", + "nullable": true, + "maxLength": 16384 + } + }, + "required": [ + "entity", + "name", + "caption", + "type" + ] + }, + "userentities": { + "writable": true, + "fields": { + "name": { + "type": "string", + "maxLength": 16384 + }, + "caption": { + "type": "string", + "maxLength": 16384 + }, + "description": { + "type": "string", + "nullable": true, + "maxLength": 16384 + }, + "show_in_sidebar_menu": { + "type": "integer" + }, + "icon_css_class": { + "type": "string", + "nullable": true, + "maxLength": 16384 + } + }, + "required": [ + "name", + "caption" + ] + }, + "userobjects": { + "writable": true, + "fields": { + "userentity_id": { + "type": "integer" + } + }, + "required": [ + "userentity_id" + ] + }, + "meal_plan": { + "writable": true, + "fields": { + "day": { + "type": "string", + "maxLength": 16384 + }, + "type": { + "type": "string", + "nullable": true, + "maxLength": 16384 + }, + "recipe_id": { + "type": "integer", + "nullable": true + }, + "recipe_servings": { + "type": "integer", + "nullable": true + }, + "note": { + "type": "string", + "nullable": true, + "maxLength": 16384 + }, + "product_id": { + "type": "integer", + "nullable": true + }, + "product_amount": { + "type": "number", + "nullable": true + }, + "product_qu_id": { + "type": "integer", + "nullable": true + }, + "done": { + "type": "integer" + }, + "section_id": { + "type": "integer" + } + }, + "required": [ + "day" + ] + }, + "stock_log": { + "writable": false, + "fields": { + "product_id": { + "type": "integer" + }, + "amount": { + "type": "number" + }, + "best_before_date": { + "type": "string", + "nullable": true, + "maxLength": 16384 + }, + "purchased_date": { + "type": "string", + "nullable": true, + "maxLength": 16384 + }, + "used_date": { + "type": "string", + "nullable": true, + "maxLength": 16384 + }, + "spoiled": { + "type": "integer" + }, + "stock_id": { + "type": "string", + "maxLength": 16384 + }, + "transaction_type": { + "type": "string", + "maxLength": 16384 + }, + "price": { + "type": "number", + "nullable": true + }, + "undone": { + "type": "integer" + }, + "undone_timestamp": { + "type": "string", + "nullable": true, + "maxLength": 16384 + }, + "opened_date": { + "type": "string", + "nullable": true, + "maxLength": 16384 + }, + "location_id": { + "type": "integer", + "nullable": true + }, + "recipe_id": { + "type": "integer", + "nullable": true + }, + "correlation_id": { + "type": "string", + "nullable": true, + "maxLength": 16384 + }, + "transaction_id": { + "type": "string", + "nullable": true, + "maxLength": 16384 + }, + "stock_row_id": { + "type": "integer", + "nullable": true + }, + "shopping_location_id": { + "type": "integer", + "nullable": true + }, + "note": { + "type": "string", + "nullable": true, + "maxLength": 16384 + } + }, + "required": [ + "product_id", + "amount", + "stock_id", + "transaction_type" + ] + }, + "stock": { + "writable": false, + "fields": { + "product_id": { + "type": "integer" + }, + "amount": { + "type": "number" + }, + "best_before_date": { + "type": "string", + "nullable": true, + "maxLength": 16384 + }, + "purchased_date": { + "type": "string", + "nullable": true, + "maxLength": 16384 + }, + "stock_id": { + "type": "string", + "maxLength": 16384 + }, + "price": { + "type": "number", + "nullable": true + }, + "open": { + "type": "integer" + }, + "opened_date": { + "type": "string", + "nullable": true, + "maxLength": 16384 + }, + "location_id": { + "type": "integer", + "nullable": true + }, + "shopping_location_id": { + "type": "integer", + "nullable": true + }, + "note": { + "type": "string", + "nullable": true, + "maxLength": 16384 + } + }, + "required": [ + "product_id", + "amount", + "stock_id" + ] + }, + "stock_current_locations": { + "writable": false, + "fields": {}, + "required": [] + }, + "chores_log": { + "writable": false, + "fields": { + "chore_id": { + "type": "integer" + }, + "tracked_time": { + "type": "string", + "nullable": true, + "maxLength": 16384 + }, + "done_by_user_id": { + "type": "integer", + "nullable": true + }, + "undone": { + "type": "integer" + }, + "undone_timestamp": { + "type": "string", + "nullable": true, + "maxLength": 16384 + }, + "skipped": { + "type": "integer" + }, + "scheduled_execution_time": { + "type": "string", + "nullable": true, + "maxLength": 16384 + } + }, + "required": [ + "chore_id" + ] + }, + "meal_plan_sections": { + "writable": true, + "fields": { + "name": { + "type": "string", + "maxLength": 16384 + }, + "sort_number": { + "type": "integer", + "nullable": true + }, + "time_info": { + "type": "string", + "nullable": true, + "maxLength": 16384 + } + }, + "required": [ + "name" + ] + }, + "products_last_purchased": { + "writable": false, + "fields": {}, + "required": [] + }, + "products_average_price": { + "writable": false, + "fields": {}, + "required": [] + }, + "quantity_unit_conversions_resolved": { + "writable": false, + "fields": {}, + "required": [] + }, + "recipes_pos_resolved": { + "writable": false, + "fields": {}, + "required": [] + }, + "battery_charge_cycles": { + "writable": false, + "fields": { + "battery_id": { + "type": "integer" + }, + "tracked_time": { + "type": "string", + "nullable": true, + "maxLength": 16384 + }, + "undone": { + "type": "integer" + }, + "undone_timestamp": { + "type": "string", + "nullable": true, + "maxLength": 16384 + } + }, + "required": [ + "battery_id" + ] + }, + "product_barcodes_view": { + "writable": false, + "fields": {}, + "required": [] + }, + "uihelper_shopping_list": { + "writable": false, + "fields": {}, + "required": [] + } + }, + "operations": [ + { + "name": "database_changed_time", + "route": "/system/db-changed-time", + "method": "get", + "properties": { + "offset": { + "type": "integer", + "minimum": 0 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 500 + } + }, + "required": [], + "path_params": {}, + "body_keys": [] + }, + { + "name": "server_time", + "route": "/system/time", + "method": "get", + "properties": { + "offset": { + "type": "integer", + "minimum": 0 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 500 + } + }, + "required": [], + "path_params": {}, + "body_keys": [] + }, + { + "name": "stock_list", + "route": "/stock", + "method": "get", + "properties": { + "offset": { + "type": "integer", + "minimum": 0 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 500 + } + }, + "required": [], + "path_params": {}, + "body_keys": [] + }, + { + "name": "stock_entry", + "route": "/stock/entry/{entryId}", + "method": "get", + "properties": { + "entry_id": { + "type": "integer", + "minimum": 1 + }, + "offset": { + "type": "integer", + "minimum": 0 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 500 + } + }, + "required": [ + "entry_id" + ], + "path_params": { + "entry_id": "entryId" + }, + "body_keys": [] + }, + { + "name": "stock_entry_put", + "route": "/stock/entry/{entryId}", + "method": "put", + "properties": { + "entry_id": { + "type": "integer", + "minimum": 1 + }, + "amount": { + "type": "number", + "minimum": 0, + "allow_decimal_string": true + }, + "best_before_date": { + "type": "string", + "format": "date", + "maxLength": 16384 + }, + "price": { + "type": "number" + }, + "open": { + "type": "boolean" + }, + "location_id": { + "type": "integer" + }, + "shopping_location_id": { + "type": "integer" + }, + "purchased_date": { + "type": "string", + "format": "date", + "maxLength": 16384 + } + }, + "required": [ + "entry_id", + "amount" + ], + "path_params": { + "entry_id": "entryId" + }, + "body_keys": [ + "amount", + "best_before_date", + "price", + "open", + "location_id", + "shopping_location_id", + "purchased_date" + ] + }, + { + "name": "stock_volatile", + "route": "/stock/volatile", + "method": "get", + "properties": { + "offset": { + "type": "integer", + "minimum": 0 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 500 + } + }, + "required": [], + "path_params": {}, + "body_keys": [] + }, + { + "name": "stock_product", + "route": "/stock/products/{productId}", + "method": "get", + "properties": { + "product_id": { + "type": "integer", + "minimum": 1 + }, + "offset": { + "type": "integer", + "minimum": 0 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 500 + } + }, + "required": [ + "product_id" + ], + "path_params": { + "product_id": "productId" + }, + "body_keys": [] + }, + { + "name": "stock_locations", + "route": "/stock/products/{productId}/locations", + "method": "get", + "properties": { + "product_id": { + "type": "integer", + "minimum": 1 + }, + "offset": { + "type": "integer", + "minimum": 0 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 500 + } + }, + "required": [ + "product_id" + ], + "path_params": { + "product_id": "productId" + }, + "body_keys": [] + }, + { + "name": "stock_entries", + "route": "/stock/products/{productId}/entries", + "method": "get", + "properties": { + "product_id": { + "type": "integer", + "minimum": 1 + }, + "offset": { + "type": "integer", + "minimum": 0 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 500 + } + }, + "required": [ + "product_id" + ], + "path_params": { + "product_id": "productId" + }, + "body_keys": [] + }, + { + "name": "stock_price_history", + "route": "/stock/products/{productId}/price-history", + "method": "get", + "properties": { + "product_id": { + "type": "integer", + "minimum": 1 + }, + "offset": { + "type": "integer", + "minimum": 0 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 500 + } + }, + "required": [ + "product_id" + ], + "path_params": { + "product_id": "productId" + }, + "body_keys": [] + }, + { + "name": "stock_add", + "route": "/stock/products/{productId}/add", + "method": "post", + "properties": { + "product_id": { + "type": "integer", + "minimum": 1 + }, + "amount": { + "type": "number", + "minimum": 0, + "allow_decimal_string": true + }, + "best_before_date": { + "type": "string", + "format": "date", + "maxLength": 16384 + }, + "transaction_type": { + "type": "string", + "enum": [ + "purchase", + "consume", + "inventory-correction", + "product-opened" + ], + "maxLength": 16384 + }, + "price": { + "type": "number" + }, + "location_id": { + "type": "integer" + }, + "shopping_location_id": { + "type": "integer" + }, + "stock_label_type": { + "type": "integer", + "enum": [ + 1 + ] + }, + "note": { + "type": "string", + "maxLength": 16384 + } + }, + "required": [ + "product_id", + "amount" + ], + "path_params": { + "product_id": "productId" + }, + "body_keys": [ + "amount", + "best_before_date", + "transaction_type", + "price", + "location_id", + "shopping_location_id", + "stock_label_type", + "note" + ] + }, + { + "name": "stock_consume", + "route": "/stock/products/{productId}/consume", + "method": "post", + "properties": { + "product_id": { + "type": "integer", + "minimum": 1 + }, + "amount": { + "type": "number", + "minimum": 0, + "allow_decimal_string": true + }, + "transaction_type": { + "type": "string", + "enum": [ + "purchase", + "consume", + "inventory-correction", + "product-opened" + ], + "maxLength": 16384 + }, + "spoiled": { + "type": "boolean" + }, + "stock_entry_id": { + "type": "string", + "maxLength": 16384 + }, + "recipe_id": { + "type": "integer" + }, + "location_id": { + "type": "integer" + }, + "exact_amount": { + "type": "boolean" + }, + "allow_subproduct_substitution": { + "type": "boolean" + } + }, + "required": [ + "product_id", + "amount" + ], + "path_params": { + "product_id": "productId" + }, + "body_keys": [ + "amount", + "transaction_type", + "spoiled", + "stock_entry_id", + "recipe_id", + "location_id", + "exact_amount", + "allow_subproduct_substitution" + ] + }, + { + "name": "stock_transfer", + "route": "/stock/products/{productId}/transfer", + "method": "post", + "properties": { + "product_id": { + "type": "integer", + "minimum": 1 + }, + "amount": { + "type": "number", + "minimum": 0, + "allow_decimal_string": true + }, + "location_id_from": { + "type": "integer" + }, + "location_id_to": { + "type": "integer" + }, + "stock_entry_id": { + "type": "string", + "maxLength": 16384 + } + }, + "required": [ + "product_id", + "amount" + ], + "path_params": { + "product_id": "productId" + }, + "body_keys": [ + "amount", + "location_id_from", + "location_id_to", + "stock_entry_id" + ] + }, + { + "name": "stock_inventory", + "route": "/stock/products/{productId}/inventory", + "method": "post", + "properties": { + "product_id": { + "type": "integer", + "minimum": 1 + }, + "new_amount": { + "type": "number", + "minimum": 0, + "allow_decimal_string": true + }, + "best_before_date": { + "type": "string", + "format": "date", + "maxLength": 16384 + }, + "shopping_location_id": { + "type": "integer" + }, + "location_id": { + "type": "integer" + }, + "price": { + "type": "number" + }, + "stock_label_type": { + "type": "integer", + "enum": [ + 1 + ] + }, + "note": { + "type": "string", + "maxLength": 16384 + } + }, + "required": [ + "product_id", + "new_amount" + ], + "path_params": { + "product_id": "productId" + }, + "body_keys": [ + "new_amount", + "best_before_date", + "shopping_location_id", + "location_id", + "price", + "stock_label_type", + "note" + ] + }, + { + "name": "stock_open", + "route": "/stock/products/{productId}/open", + "method": "post", + "properties": { + "product_id": { + "type": "integer", + "minimum": 1 + }, + "amount": { + "type": "number", + "minimum": 0, + "allow_decimal_string": true + }, + "stock_entry_id": { + "type": "string", + "maxLength": 16384 + }, + "allow_subproduct_substitution": { + "type": "boolean" + } + }, + "required": [ + "product_id", + "amount" + ], + "path_params": { + "product_id": "productId" + }, + "body_keys": [ + "amount", + "stock_entry_id", + "allow_subproduct_substitution" + ] + }, + { + "name": "stock_copy", + "route": "/stock/products/{productId}/copy", + "method": "post", + "properties": { + "product_id": { + "type": "integer", + "minimum": 1 + } + }, + "required": [ + "product_id" + ], + "path_params": { + "product_id": "productId" + }, + "body_keys": [] + }, + { + "name": "stock_merge", + "route": "/stock/products/{productIdToKeep}/merge/{productIdToRemove}", + "method": "post", + "properties": { + "product_id_to_keep": { + "type": "integer", + "minimum": 1 + }, + "product_id_to_remove": { + "type": "integer", + "minimum": 1 + } + }, + "required": [ + "product_id_to_keep", + "product_id_to_remove" + ], + "path_params": { + "product_id_to_keep": "productIdToKeep", + "product_id_to_remove": "productIdToRemove" + }, + "body_keys": [] + }, + { + "name": "stock_product_by_barcode", + "route": "/stock/products/by-barcode/{barcode}", + "method": "get", + "properties": { + "barcode": { + "type": "string", + "maxLength": 128 + }, + "offset": { + "type": "integer", + "minimum": 0 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 500 + } + }, + "required": [ + "barcode" + ], + "path_params": { + "barcode": "barcode" + }, + "body_keys": [] + }, + { + "name": "stock_add_by_barcode", + "route": "/stock/products/by-barcode/{barcode}/add", + "method": "post", + "properties": { + "barcode": { + "type": "string", + "maxLength": 128 + }, + "amount": { + "type": "number", + "minimum": 0, + "allow_decimal_string": true + }, + "best_before_date": { + "type": "string", + "format": "date", + "maxLength": 16384 + }, + "transaction_type": { + "type": "string", + "enum": [ + "purchase", + "consume", + "inventory-correction", + "product-opened" + ], + "maxLength": 16384 + }, + "price": { + "type": "number" + }, + "location_id": { + "type": "integer" + } + }, + "required": [ + "barcode", + "amount" + ], + "path_params": { + "barcode": "barcode" + }, + "body_keys": [ + "amount", + "best_before_date", + "transaction_type", + "price", + "location_id" + ] + }, + { + "name": "stock_consume_by_barcode", + "route": "/stock/products/by-barcode/{barcode}/consume", + "method": "post", + "properties": { + "barcode": { + "type": "string", + "maxLength": 128 + }, + "amount": { + "type": "number", + "minimum": 0, + "allow_decimal_string": true + }, + "transaction_type": { + "type": "string", + "enum": [ + "purchase", + "consume", + "inventory-correction", + "product-opened" + ], + "maxLength": 16384 + }, + "spoiled": { + "type": "boolean" + }, + "stock_entry_id": { + "type": "string", + "maxLength": 16384 + }, + "recipe_id": { + "type": "integer" + }, + "location_id": { + "type": "integer" + }, + "exact_amount": { + "type": "boolean" + }, + "allow_subproduct_substitution": { + "type": "boolean" + } + }, + "required": [ + "barcode", + "amount" + ], + "path_params": { + "barcode": "barcode" + }, + "body_keys": [ + "amount", + "transaction_type", + "spoiled", + "stock_entry_id", + "recipe_id", + "location_id", + "exact_amount", + "allow_subproduct_substitution" + ] + }, + { + "name": "stock_transfer_by_barcode", + "route": "/stock/products/by-barcode/{barcode}/transfer", + "method": "post", + "properties": { + "barcode": { + "type": "string", + "maxLength": 128 + }, + "amount": { + "type": "number", + "minimum": 0, + "allow_decimal_string": true + }, + "location_id_from": { + "type": "integer" + }, + "location_id_to": { + "type": "integer" + }, + "stock_entry_id": { + "type": "string", + "maxLength": 16384 + } + }, + "required": [ + "barcode", + "amount" + ], + "path_params": { + "barcode": "barcode" + }, + "body_keys": [ + "amount", + "location_id_from", + "location_id_to", + "stock_entry_id" + ] + }, + { + "name": "stock_inventory_by_barcode", + "route": "/stock/products/by-barcode/{barcode}/inventory", + "method": "post", + "properties": { + "barcode": { + "type": "string", + "maxLength": 128 + }, + "new_amount": { + "type": "number", + "minimum": 0, + "allow_decimal_string": true + }, + "best_before_date": { + "type": "string", + "format": "date", + "maxLength": 16384 + }, + "location_id": { + "type": "integer" + }, + "price": { + "type": "number" + } + }, + "required": [ + "barcode", + "new_amount" + ], + "path_params": { + "barcode": "barcode" + }, + "body_keys": [ + "new_amount", + "best_before_date", + "location_id", + "price" + ] + }, + { + "name": "stock_open_by_barcode", + "route": "/stock/products/by-barcode/{barcode}/open", + "method": "post", + "properties": { + "barcode": { + "type": "string", + "maxLength": 128 + }, + "amount": { + "type": "number", + "minimum": 0, + "allow_decimal_string": true + }, + "stock_entry_id": { + "type": "string", + "maxLength": 16384 + }, + "allow_subproduct_substitution": { + "type": "boolean" + } + }, + "required": [ + "barcode", + "amount" + ], + "path_params": { + "barcode": "barcode" + }, + "body_keys": [ + "amount", + "stock_entry_id", + "allow_subproduct_substitution" + ] + }, + { + "name": "stock_location_entries", + "route": "/stock/locations/{locationId}/entries", + "method": "get", + "properties": { + "location_id": { + "type": "integer", + "minimum": 1 + }, + "offset": { + "type": "integer", + "minimum": 0 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 500 + } + }, + "required": [ + "location_id" + ], + "path_params": { + "location_id": "locationId" + }, + "body_keys": [] + }, + { + "name": "shopping_add_missing_products", + "route": "/stock/shoppinglist/add-missing-products", + "method": "post", + "properties": { + "list_id": { + "type": "integer" + } + }, + "required": [], + "path_params": {}, + "body_keys": [ + "list_id" + ] + }, + { + "name": "shopping_add_overdue_products", + "route": "/stock/shoppinglist/add-overdue-products", + "method": "post", + "properties": { + "list_id": { + "type": "integer" + } + }, + "required": [], + "path_params": {}, + "body_keys": [ + "list_id" + ] + }, + { + "name": "shopping_add_expired_products", + "route": "/stock/shoppinglist/add-expired-products", + "method": "post", + "properties": { + "list_id": { + "type": "integer" + } + }, + "required": [], + "path_params": {}, + "body_keys": [ + "list_id" + ] + }, + { + "name": "shopping_clear", + "route": "/stock/shoppinglist/clear", + "method": "post", + "properties": { + "list_id": { + "type": "integer" + }, + "done_only": { + "type": "boolean" + } + }, + "required": [], + "path_params": {}, + "body_keys": [ + "list_id", + "done_only" + ] + }, + { + "name": "shopping_add_product", + "route": "/stock/shoppinglist/add-product", + "method": "post", + "properties": { + "product_id": { + "type": "integer" + }, + "qu_id": { + "type": "integer" + }, + "list_id": { + "type": "integer" + }, + "product_amount": { + "type": "number" + }, + "note": { + "type": "string", + "maxLength": 16384 + } + }, + "required": [], + "path_params": {}, + "body_keys": [ + "product_id", + "qu_id", + "list_id", + "product_amount", + "note" + ] + }, + { + "name": "shopping_remove_product", + "route": "/stock/shoppinglist/remove-product", + "method": "post", + "properties": { + "product_id": { + "type": "integer" + }, + "list_id": { + "type": "integer" + }, + "product_amount": { + "type": "number" + } + }, + "required": [], + "path_params": {}, + "body_keys": [ + "product_id", + "list_id", + "product_amount" + ] + }, + { + "name": "stock_booking", + "route": "/stock/bookings/{bookingId}", + "method": "get", + "properties": { + "booking_id": { + "type": "integer", + "minimum": 1 + }, + "offset": { + "type": "integer", + "minimum": 0 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 500 + } + }, + "required": [ + "booking_id" + ], + "path_params": { + "booking_id": "bookingId" + }, + "body_keys": [] + }, + { + "name": "stock_undo_booking", + "route": "/stock/bookings/{bookingId}/undo", + "method": "post", + "properties": { + "booking_id": { + "type": "integer", + "minimum": 1 + } + }, + "required": [ + "booking_id" + ], + "path_params": { + "booking_id": "bookingId" + }, + "body_keys": [] + }, + { + "name": "stock_transaction", + "route": "/stock/transactions/{transactionId}", + "method": "get", + "properties": { + "transaction_id": { + "type": "string", + "maxLength": 128 + }, + "offset": { + "type": "integer", + "minimum": 0 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 500 + } + }, + "required": [ + "transaction_id" + ], + "path_params": { + "transaction_id": "transactionId" + }, + "body_keys": [] + }, + { + "name": "stock_undo_transaction", + "route": "/stock/transactions/{transactionId}/undo", + "method": "post", + "properties": { + "transaction_id": { + "type": "string", + "maxLength": 128 + } + }, + "required": [ + "transaction_id" + ], + "path_params": { + "transaction_id": "transactionId" + }, + "body_keys": [] + }, + { + "name": "recipe_add_not_fulfilled_products_to_shoppinglist", + "route": "/recipes/{recipeId}/add-not-fulfilled-products-to-shoppinglist", + "method": "post", + "properties": { + "recipe_id": { + "type": "string", + "maxLength": 128 + }, + "excludedProductIds": { + "type": "array", + "items": { + "type": "integer" + }, + "maxItems": 500 + } + }, + "required": [ + "recipe_id" + ], + "path_params": { + "recipe_id": "recipeId" + }, + "body_keys": [ + "excludedProductIds" + ] + }, + { + "name": "recipe_fulfillment", + "route": "/recipes/{recipeId}/fulfillment", + "method": "get", + "properties": { + "recipe_id": { + "type": "string", + "maxLength": 128 + }, + "offset": { + "type": "integer", + "minimum": 0 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 500 + } + }, + "required": [ + "recipe_id" + ], + "path_params": { + "recipe_id": "recipeId" + }, + "body_keys": [] + }, + { + "name": "recipe_consume", + "route": "/recipes/{recipeId}/consume", + "method": "post", + "properties": { + "recipe_id": { + "type": "string", + "maxLength": 128 + } + }, + "required": [ + "recipe_id" + ], + "path_params": { + "recipe_id": "recipeId" + }, + "body_keys": [] + }, + { + "name": "recipes_fulfillment", + "route": "/recipes/fulfillment", + "method": "get", + "properties": { + "offset": { + "type": "integer", + "minimum": 0 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 500 + } + }, + "required": [], + "path_params": {}, + "body_keys": [] + }, + { + "name": "recipe_copy", + "route": "/recipes/{recipeId}/copy", + "method": "post", + "properties": { + "recipe_id": { + "type": "integer", + "minimum": 1 + } + }, + "required": [ + "recipe_id" + ], + "path_params": { + "recipe_id": "recipeId" + }, + "body_keys": [] + }, + { + "name": "chores_list", + "route": "/chores", + "method": "get", + "properties": { + "offset": { + "type": "integer", + "minimum": 0 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 500 + } + }, + "required": [], + "path_params": {}, + "body_keys": [] + }, + { + "name": "chore_details", + "route": "/chores/{choreId}", + "method": "get", + "properties": { + "chore_id": { + "type": "integer", + "minimum": 1 + }, + "offset": { + "type": "integer", + "minimum": 0 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 500 + } + }, + "required": [ + "chore_id" + ], + "path_params": { + "chore_id": "choreId" + }, + "body_keys": [] + }, + { + "name": "chore_execute", + "route": "/chores/{choreId}/execute", + "method": "post", + "properties": { + "chore_id": { + "type": "integer", + "minimum": 1 + }, + "tracked_time": { + "type": "string", + "format": "date-time", + "maxLength": 16384 + }, + "done_by": { + "type": "integer" + }, + "skipped": { + "type": "boolean" + } + }, + "required": [ + "chore_id" + ], + "path_params": { + "chore_id": "choreId" + }, + "body_keys": [ + "tracked_time", + "done_by", + "skipped" + ] + }, + { + "name": "chore_undo_execution", + "route": "/chores/executions/{executionId}/undo", + "method": "post", + "properties": { + "execution_id": { + "type": "integer", + "minimum": 1 + } + }, + "required": [ + "execution_id" + ], + "path_params": { + "execution_id": "executionId" + }, + "body_keys": [] + }, + { + "name": "chores_recalculate_assignments", + "route": "/chores/executions/calculate-next-assignments", + "method": "post", + "properties": { + "chore_id": { + "type": "integer" + } + }, + "required": [], + "path_params": {}, + "body_keys": [ + "chore_id" + ] + }, + { + "name": "chore_merge", + "route": "/chores/{choreIdToKeep}/merge/{choreIdToRemove}", + "method": "post", + "properties": { + "chore_id_to_keep": { + "type": "integer", + "minimum": 1 + }, + "chore_id_to_remove": { + "type": "integer", + "minimum": 1 + } + }, + "required": [ + "chore_id_to_keep", + "chore_id_to_remove" + ], + "path_params": { + "chore_id_to_keep": "choreIdToKeep", + "chore_id_to_remove": "choreIdToRemove" + }, + "body_keys": [] + }, + { + "name": "batteries_list", + "route": "/batteries", + "method": "get", + "properties": { + "offset": { + "type": "integer", + "minimum": 0 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 500 + } + }, + "required": [], + "path_params": {}, + "body_keys": [] + }, + { + "name": "battery_details", + "route": "/batteries/{batteryId}", + "method": "get", + "properties": { + "battery_id": { + "type": "integer", + "minimum": 1 + }, + "offset": { + "type": "integer", + "minimum": 0 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 500 + } + }, + "required": [ + "battery_id" + ], + "path_params": { + "battery_id": "batteryId" + }, + "body_keys": [] + }, + { + "name": "battery_charge", + "route": "/batteries/{batteryId}/charge", + "method": "post", + "properties": { + "battery_id": { + "type": "integer", + "minimum": 1 + }, + "tracked_time": { + "type": "string", + "format": "date-time", + "maxLength": 16384 + } + }, + "required": [ + "battery_id" + ], + "path_params": { + "battery_id": "batteryId" + }, + "body_keys": [ + "tracked_time" + ] + }, + { + "name": "battery_undo_charge", + "route": "/batteries/charge-cycles/{chargeCycleId}/undo", + "method": "post", + "properties": { + "charge_cycle_id": { + "type": "integer", + "minimum": 1 + } + }, + "required": [ + "charge_cycle_id" + ], + "path_params": { + "charge_cycle_id": "chargeCycleId" + }, + "body_keys": [] + }, + { + "name": "tasks_list", + "route": "/tasks", + "method": "get", + "properties": { + "offset": { + "type": "integer", + "minimum": 0 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 500 + } + }, + "required": [], + "path_params": {}, + "body_keys": [] + }, + { + "name": "task_complete", + "route": "/tasks/{taskId}/complete", + "method": "post", + "properties": { + "task_id": { + "type": "integer", + "minimum": 1 + }, + "done_time": { + "type": "string", + "format": "date-time", + "maxLength": 16384 + } + }, + "required": [ + "task_id" + ], + "path_params": { + "task_id": "taskId" + }, + "body_keys": [ + "done_time" + ] + }, + { + "name": "task_undo", + "route": "/tasks/{taskId}/undo", + "method": "post", + "properties": { + "task_id": { + "type": "integer", + "minimum": 1 + } + }, + "required": [ + "task_id" + ], + "path_params": { + "task_id": "taskId" + }, + "body_keys": [] + } + ] +} diff --git a/src/catalog/mod.rs b/src/catalog/mod.rs new file mode 100644 index 0000000..ae429ed --- /dev/null +++ b/src/catalog/mod.rs @@ -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 = 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 { + 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, + pub body_keys: Vec, +} +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 { + let mut ops = Vec::new(); + let enum_entities = entities().keys().collect::>(); + let writable = entities() + .iter() + .filter(|(_, v)| v["writable"] == true) + .map(|(n, _)| n) + .collect::>(); + 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 { + 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::() + .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 }) +} diff --git a/src/catalog/schemas.rs b/src/catalog/schemas.rs new file mode 100644 index 0000000..5142628 --- /dev/null +++ b/src/catalog/schemas.rs @@ -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(()) +} diff --git a/src/tools/mod.rs b/src/tools/mod.rs new file mode 100644 index 0000000..77f1be0 --- /dev/null +++ b/src/tools/mod.rs @@ -0,0 +1,2 @@ +pub mod records; +pub mod validation; diff --git a/src/tools/records.rs b/src/tools/records.rs new file mode 100644 index 0000000..686041a --- /dev/null +++ b/src/tools/records.rs @@ -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 { + 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 { + 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::>(); + 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, + }) +} diff --git a/src/tools/validation.rs b/src/tools/validation.rs new file mode 100644 index 0000000..38b3c0c --- /dev/null +++ b/src/tools/validation.rs @@ -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 { + 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 { + 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::>(); + 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::>()}), + ), + }), + } +} +pub fn convert_quantity( + amount: Decimal, + from: RecordId, + to: RecordId, + conversions: &[Conversion], +) -> Result { + 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::>(); + 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> { + 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::() + .map_err(|_| AppError::input())?; + output.push(Conversion { + from: record_id(&row["from_qu_id"])?, + to: record_id(&row["to_qu_id"])?, + factor, + }); + } + } + Ok(output) +} diff --git a/tests/catalog.rs b/tests/catalog.rs new file mode 100644 index 0000000..b9d53b5 --- /dev/null +++ b/tests/catalog.rs @@ -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(); +}