From 0513a104a9d2284b9ee89298f788b56a562c42e8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 16 Mar 2026 14:28:03 -0400 Subject: [PATCH] Add Flask REST API test server for External System Gateway and Inbound API testing --- infra/README.md | 10 +- infra/docker-compose.yml | 10 ++ infra/restapi/Dockerfile | 6 + infra/restapi/app.py | 194 ++++++++++++++++++++++++++++++++ infra/tools/requirements.txt | 1 + infra/tools/restapi_tool.py | 152 +++++++++++++++++++++++++ test_infra.md | 19 +++- test_infra_restapi.md | 208 +++++++++++++++++++++++++++++++++++ 8 files changed, 593 insertions(+), 7 deletions(-) create mode 100644 infra/restapi/Dockerfile create mode 100644 infra/restapi/app.py create mode 100644 infra/tools/restapi_tool.py create mode 100644 test_infra_restapi.md diff --git a/infra/README.md b/infra/README.md index 343b114..67e6619 100644 --- a/infra/README.md +++ b/infra/README.md @@ -8,7 +8,7 @@ Local Docker-based test services for ScadaLink development. docker compose up -d ``` -This starts four services: +This starts five services: | Service | Port | Purpose | |---------|------|---------| @@ -16,6 +16,7 @@ This starts four services: | LDAP (GLAuth) | 3893 | Lightweight LDAP with test users/groups matching ScadaLink roles | | MS SQL 2022 | 1433 | Configuration and machine data databases | | SMTP (Mailpit) | 1025 (SMTP), 8025 (web) | Email capture for notification testing | +| REST API (Flask) | 5200 | External REST API for Gateway and Inbound API testing | ## First-Time SQL Setup @@ -38,7 +39,7 @@ docker compose down **Stop a single service** (leave the others running): ```bash -docker compose stop opcua # or: ldap, mssql, smtp +docker compose stop opcua # or: ldap, mssql, smtp, restapi docker compose start opcua # bring it back without recreating ``` @@ -71,12 +72,13 @@ pip install -r tools/requirements.txt > The `.venv` directory is gitignored. -**Quick readiness check** (all four services, with venv active): +**Quick readiness check** (all five services, with venv active): ```bash python tools/opcua_tool.py check python tools/ldap_tool.py check python tools/mssql_tool.py check python tools/smtp_tool.py check +python tools/restapi_tool.py check ``` | Tool | Service | Key Commands | @@ -85,6 +87,7 @@ python tools/smtp_tool.py check | `tools/ldap_tool.py` | LDAP | `check`, `bind`, `search`, `users`, `groups` | | `tools/mssql_tool.py` | MS SQL | `check`, `setup`, `query`, `tables` | | `tools/smtp_tool.py` | SMTP (Mailpit) | `check`, `send`, `list`, `read`, `clear` | +| `tools/restapi_tool.py` | REST API (Flask) | `check`, `call`, `methods` | Each tool supports `--help` for full usage. See the per-service docs below for detailed examples. @@ -97,3 +100,4 @@ See the project root for per-service setup guides: - [test_infra_ldap.md](../test_infra_ldap.md) — LDAP server details - [test_infra_db.md](../test_infra_db.md) — MS SQL database details - [test_infra_smtp.md](../test_infra_smtp.md) — SMTP server details (Mailpit) +- [test_infra_restapi.md](../test_infra_restapi.md) — REST API server details (Flask) diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index f68d462..6fbe00e 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -53,5 +53,15 @@ services: MP_MAX_MESSAGES: 500 restart: unless-stopped + restapi: + build: ./restapi + container_name: scadalink-restapi + ports: + - "5200:5200" + environment: + API_NO_AUTH: 0 + PORT: 5200 + restart: unless-stopped + volumes: scadalink-mssql-data: diff --git a/infra/restapi/Dockerfile b/infra/restapi/Dockerfile new file mode 100644 index 0000000..f670a84 --- /dev/null +++ b/infra/restapi/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.13-slim +WORKDIR /app +RUN pip install flask +COPY app.py . +EXPOSE 5200 +CMD ["python", "app.py"] diff --git a/infra/restapi/app.py b/infra/restapi/app.py new file mode 100644 index 0000000..347205b --- /dev/null +++ b/infra/restapi/app.py @@ -0,0 +1,194 @@ +"""External REST API test server for ScadaLink test infrastructure.""" + +import os +import time +import uuid + +from flask import Flask, jsonify, request + +app = Flask(__name__) + +START_TIME = time.time() +API_KEY = "scadalink-test-key-1" +NO_AUTH = os.environ.get("API_NO_AUTH", "0") == "1" + + +@app.before_request +def check_auth(): + if NO_AUTH: + return None + if request.path == "/api/Ping" and request.method == "GET": + return None # health check is unauthenticated + + api_key = request.headers.get("X-API-Key") + if api_key == API_KEY: + return None + + auth = request.authorization + if auth and auth.type == "basic": + return None # accept any basic auth credentials + + return jsonify({"error": "Unauthorized", "message": "Provide X-API-Key header or Basic auth"}), 401 + + +# --------------------------------------------------------------------------- +# Simple methods +# --------------------------------------------------------------------------- + +@app.route("/api/Ping", methods=["GET"]) +def ping(): + return jsonify({"pong": True}) + + +@app.route("/api/Add", methods=["POST"]) +def add(): + data = request.get_json(force=True) + a = data.get("a", 0) + b = data.get("b", 0) + return jsonify({"result": a + b}) + + +@app.route("/api/Multiply", methods=["POST"]) +def multiply(): + data = request.get_json(force=True) + a = data.get("a", 0) + b = data.get("b", 0) + return jsonify({"result": a * b}) + + +@app.route("/api/Echo", methods=["POST"]) +def echo(): + data = request.get_json(force=True) + return jsonify({"message": data.get("message", "")}) + + +@app.route("/api/GetStatus", methods=["POST"]) +def get_status(): + uptime = round(time.time() - START_TIME, 1) + return jsonify({"status": "running", "uptime": uptime}) + + +# --------------------------------------------------------------------------- +# Complex methods (nested objects + lists) +# --------------------------------------------------------------------------- + +@app.route("/api/GetProductionReport", methods=["POST"]) +def get_production_report(): + data = request.get_json(force=True) + site_id = data.get("siteId", "Unknown") + return jsonify({ + "siteName": f"Site {site_id}", + "totalUnits": 14250, + "lines": [ + {"lineName": "Line-1", "units": 8200, "efficiency": 92.5}, + {"lineName": "Line-2", "units": 6050, "efficiency": 88.1}, + ], + }) + + +@app.route("/api/GetRecipe", methods=["POST"]) +def get_recipe(): + data = request.get_json(force=True) + recipe_id = data.get("recipeId", "R-000") + return jsonify({ + "recipeId": recipe_id, + "name": "Standard Mix", + "version": 3, + "ingredients": [ + {"name": "Material-A", "quantity": 45.0, "unit": "kg"}, + {"name": "Material-B", "quantity": 12.5, "unit": "L"}, + ], + }) + + +@app.route("/api/SubmitBatch", methods=["POST"]) +def submit_batch(): + data = request.get_json(force=True) + batch_id = f"BATCH-{uuid.uuid4().hex[:8].upper()}" + item_count = len(data.get("items", [])) + return jsonify({ + "batchId": batch_id, + "accepted": True, + "itemCount": item_count, + }) + + +@app.route("/api/GetEquipmentStatus", methods=["POST"]) +def get_equipment_status(): + data = request.get_json(force=True) + site_id = data.get("siteId", "Unknown") + return jsonify({ + "siteId": site_id, + "equipment": [ + { + "equipmentId": "PUMP-001", + "name": "Feed Pump A", + "status": {"state": "running", "health": 98.5, "lastMaintenance": "2026-02-15"}, + }, + { + "equipmentId": "TANK-001", + "name": "Mix Tank 1", + "status": {"state": "idle", "health": 100.0, "lastMaintenance": "2026-03-01"}, + }, + { + "equipmentId": "CONV-001", + "name": "Conveyor B", + "status": {"state": "alarm", "health": 72.3, "lastMaintenance": "2026-01-20"}, + }, + ], + }) + + +# --------------------------------------------------------------------------- +# Error simulation +# --------------------------------------------------------------------------- + +@app.route("/api/SimulateTimeout", methods=["POST"]) +def simulate_timeout(): + data = request.get_json(force=True) + seconds = min(data.get("seconds", 5), 60) # cap at 60s + time.sleep(seconds) + return jsonify({"delayed": True, "seconds": seconds}) + + +@app.route("/api/SimulateError", methods=["POST"]) +def simulate_error(): + data = request.get_json(force=True) + code = data.get("code", 500) + if code < 400 or code > 599: + code = 500 + return jsonify({"error": True, "code": code}), code + + +# --------------------------------------------------------------------------- +# Method discovery +# --------------------------------------------------------------------------- + +@app.route("/api/methods", methods=["GET"]) +def list_methods(): + """List all available API methods.""" + methods = [ + {"method": "Ping", "httpMethod": "GET", "path": "/api/Ping", "description": "Health check"}, + {"method": "Add", "httpMethod": "POST", "path": "/api/Add", "params": {"a": "number", "b": "number"}, "description": "Add two numbers"}, + {"method": "Multiply", "httpMethod": "POST", "path": "/api/Multiply", "params": {"a": "number", "b": "number"}, "description": "Multiply two numbers"}, + {"method": "Echo", "httpMethod": "POST", "path": "/api/Echo", "params": {"message": "string"}, "description": "Echo back input"}, + {"method": "GetStatus", "httpMethod": "POST", "path": "/api/GetStatus", "params": {}, "description": "Server status and uptime"}, + {"method": "GetProductionReport", "httpMethod": "POST", "path": "/api/GetProductionReport", "params": {"siteId": "string", "startDate": "string", "endDate": "string"}, "description": "Production report with line details"}, + {"method": "GetRecipe", "httpMethod": "POST", "path": "/api/GetRecipe", "params": {"recipeId": "string"}, "description": "Recipe with ingredients list"}, + {"method": "SubmitBatch", "httpMethod": "POST", "path": "/api/SubmitBatch", "params": {"siteId": "string", "recipeId": "string", "items": "list"}, "description": "Submit a batch with items"}, + {"method": "GetEquipmentStatus", "httpMethod": "POST", "path": "/api/GetEquipmentStatus", "params": {"siteId": "string"}, "description": "Equipment status with nested objects"}, + {"method": "SimulateTimeout", "httpMethod": "POST", "path": "/api/SimulateTimeout", "params": {"seconds": "number"}, "description": "Delay response by N seconds"}, + {"method": "SimulateError", "httpMethod": "POST", "path": "/api/SimulateError", "params": {"code": "number"}, "description": "Return specified HTTP error code"}, + ] + return jsonify({"methods": methods}) + + +if __name__ == "__main__": + port = int(os.environ.get("PORT", 5200)) + no_auth_flag = "--no-auth" in __import__("sys").argv + if no_auth_flag: + NO_AUTH = True + if NO_AUTH: + print(" * Auth disabled (--no-auth or API_NO_AUTH=1)") + print(f" * API key: {API_KEY}") + app.run(host="0.0.0.0", port=port) diff --git a/infra/tools/requirements.txt b/infra/tools/requirements.txt index 1a28b42..bc92aea 100644 --- a/infra/tools/requirements.txt +++ b/infra/tools/requirements.txt @@ -1,3 +1,4 @@ opcua>=0.98.0 ldap3>=2.9 pymssql>=2.2.0 +requests>=2.31.0 diff --git a/infra/tools/restapi_tool.py b/infra/tools/restapi_tool.py new file mode 100644 index 0000000..3d20486 --- /dev/null +++ b/infra/tools/restapi_tool.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""REST API client tool for ScadaLink test infrastructure.""" + +import argparse +import json +import sys + +import requests + + +DEFAULT_URL = "http://localhost:5200" +DEFAULT_API_KEY = "scadalink-test-key-1" + + +def cmd_check(args): + """Test API server connectivity and report status.""" + headers = {"X-API-Key": args.api_key} + + # Test health endpoint (no auth required) + try: + resp = requests.get(f"{args.url}/api/Ping", timeout=5) + ping_ok = resp.status_code == 200 + except Exception as e: + ping_ok = False + ping_err = str(e) + + print(f"Ping ({args.url}/api/Ping): {'OK' if ping_ok else 'FAILED - ' + ping_err}") + + if not ping_ok: + sys.exit(1) + + # Test authenticated endpoint + try: + resp = requests.post(f"{args.url}/api/GetStatus", json={}, headers=headers, timeout=5) + if resp.status_code == 200: + data = resp.json() + print(f"Status: {data.get('status', 'unknown')}") + print(f"Uptime: {data.get('uptime', 0)}s") + auth_ok = True + elif resp.status_code == 401: + print("Auth: FAILED - invalid API key") + auth_ok = False + else: + print(f"Status check: HTTP {resp.status_code}") + auth_ok = False + except Exception as e: + print(f"Status check: FAILED - {e}") + auth_ok = False + + # List available methods + try: + resp = requests.get(f"{args.url}/api/methods", headers=headers, timeout=5) + if resp.status_code == 200: + methods = resp.json().get("methods", []) + print(f"Available methods: {len(methods)}") + except Exception: + pass + + if ping_ok and auth_ok: + print("\nREST API server is healthy.") + else: + sys.exit(1) + + +def cmd_call(args): + """Call an API method.""" + headers = {"X-API-Key": args.api_key, "Content-Type": "application/json"} + url = f"{args.url}/api/{args.method}" + + params = {} + if args.params: + try: + params = json.loads(args.params) + except json.JSONDecodeError as e: + print(f"Error: invalid JSON params: {e}", file=sys.stderr) + sys.exit(1) + + try: + if args.method == "Ping": + resp = requests.get(url, headers=headers, timeout=args.timeout) + else: + resp = requests.post(url, json=params, headers=headers, timeout=args.timeout) + except requests.exceptions.Timeout: + print(f"Error: request timed out after {args.timeout}s", file=sys.stderr) + sys.exit(1) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + print(f"HTTP {resp.status_code}") + try: + print(json.dumps(resp.json(), indent=2)) + except Exception: + print(resp.text) + + if resp.status_code >= 400: + sys.exit(1) + + +def cmd_methods(args): + """List all available API methods.""" + headers = {"X-API-Key": args.api_key} + + try: + resp = requests.get(f"{args.url}/api/methods", headers=headers, timeout=5) + if resp.status_code != 200: + print(f"Error: HTTP {resp.status_code}", file=sys.stderr) + sys.exit(1) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + methods = resp.json().get("methods", []) + print(f"{'Method':<25} {'HTTP':<6} {'Path':<30} {'Description'}") + print("-" * 95) + + for m in methods: + print(f"{m['method']:<25} {m['httpMethod']:<6} {m['path']:<30} {m.get('description', '')}") + if m.get("params"): + params_str = ", ".join(f"{k}: {v}" for k, v in m["params"].items()) + print(f" {'Params:':<23} {params_str}") + + +def main(): + parser = argparse.ArgumentParser(description="REST API client tool for ScadaLink test infrastructure") + parser.add_argument("--url", default=DEFAULT_URL, help=f"API base URL (default: {DEFAULT_URL})") + parser.add_argument("--api-key", default=DEFAULT_API_KEY, help=f"API key (default: {DEFAULT_API_KEY})") + + sub = parser.add_subparsers(dest="command", required=True) + + sub.add_parser("check", help="Test API connectivity and report status") + + call_p = sub.add_parser("call", help="Call an API method") + call_p.add_argument("--method", required=True, help="Method name (e.g. Add, GetProductionReport)") + call_p.add_argument("--params", help="JSON params (e.g. '{\"a\": 2, \"b\": 3}')") + call_p.add_argument("--timeout", type=float, default=30, help="Request timeout in seconds (default: 30)") + + sub.add_parser("methods", help="List all available API methods") + + args = parser.parse_args() + + commands = { + "check": cmd_check, + "call": cmd_call, + "methods": cmd_methods, + } + + commands[args.command](args) + + +if __name__ == "__main__": + main() diff --git a/test_infra.md b/test_infra.md index 561cdaf..6dcfcb4 100644 --- a/test_infra.md +++ b/test_infra.md @@ -1,6 +1,6 @@ # Test Infrastructure -This document describes the local Docker-based test infrastructure for ScadaLink development. Four services provide the external dependencies needed to run and test the system locally. +This document describes the local Docker-based test infrastructure for ScadaLink development. Five services provide the external dependencies needed to run and test the system locally. ## Services @@ -10,6 +10,7 @@ This document describes the local Docker-based test infrastructure for ScadaLink | LDAP Server | `glauth/glauth:latest` | 3893 | `infra/glauth/config.toml` | | MS SQL 2022 | `mcr.microsoft.com/mssql/server:2022-latest` | 1433 | `infra/mssql/setup.sql` | | SMTP (Mailpit) | `axllent/mailpit:latest` | 1025 (SMTP), 8025 (web) | Environment vars | +| REST API (Flask) | Custom build (`infra/restapi/Dockerfile`) | 5200 | `infra/restapi/app.py` | ## Quick Start @@ -34,6 +35,7 @@ Each service has a dedicated document with configuration details, verification s - [test_infra_ldap.md](test_infra_ldap.md) — LDAP test server (GLAuth) - [test_infra_db.md](test_infra_db.md) — MS SQL 2022 database - [test_infra_smtp.md](test_infra_smtp.md) — SMTP test server (Mailpit) +- [test_infra_restapi.md](test_infra_restapi.md) — REST API test server (Flask) ## Connection Strings @@ -60,6 +62,13 @@ For use in `appsettings.Development.json`: "AuthMode": "None", "FromAddress": "scada-notifications@company.com", "ConnectionTimeout": 30 + }, + "ExternalSystems": { + "TestApi": { + "BaseUrl": "http://localhost:5200", + "AuthMode": "ApiKey", + "ApiKey": "scadalink-test-key-1" + } } } ``` @@ -69,7 +78,7 @@ For use in `appsettings.Development.json`: ```bash cd infra docker compose down # stop containers, preserve SQL data volume -docker compose stop opcua # stop a single service (also: ldap, mssql, smtp) +docker compose stop opcua # stop a single service (also: ldap, mssql, smtp, restapi) ``` **Full teardown** (removes volumes, optionally images and venv): @@ -86,11 +95,13 @@ After a full teardown, the next `docker compose up -d` starts fresh — re-run t ``` infra/ - docker-compose.yml # All four services + docker-compose.yml # All five services teardown.sh # Teardown script (volumes, images, venv) glauth/config.toml # LDAP users and groups mssql/setup.sql # Database and user creation opcua/nodes.json # Custom OPC UA tag definitions - tools/ # Python CLI tools (opcua, ldap, mssql, smtp) + restapi/app.py # Flask REST API server + restapi/Dockerfile # REST API container build + tools/ # Python CLI tools (opcua, ldap, mssql, smtp, restapi) README.md # Quick-start for the infra folder ``` diff --git a/test_infra_restapi.md b/test_infra_restapi.md new file mode 100644 index 0000000..7bee81d --- /dev/null +++ b/test_infra_restapi.md @@ -0,0 +1,208 @@ +# Test Infrastructure: REST API Server (Flask) + +## Overview + +The test REST API server is a lightweight Python/Flask application that provides HTTP endpoints matching the patterns used by ScadaLink's External System Gateway and Inbound API components. It supports simple parameter/response methods, complex nested object/list methods, authentication, and error simulation. + +## Image & Ports + +- **Image**: Custom build from `infra/restapi/Dockerfile` (Python 3.13 + Flask) +- **API port**: 5200 + +## Configuration + +| Setting | Value | Description | +|---------|-------|-------------| +| `API_NO_AUTH` | `0` | Set to `1` to disable API key authentication | +| `PORT` | `5200` | Server listen port | + +## Authentication + +The server validates requests using one of two methods: + +- **API Key**: `X-API-Key: scadalink-test-key-1` header +- **Basic Auth**: Any username/password (accepts all credentials) + +The `GET /api/Ping` endpoint is always unauthenticated (health check). + +Auth can be disabled entirely by setting `API_NO_AUTH=1` in the Docker Compose environment or passing `--no-auth` when running directly. + +For `appsettings.Development.json` (External System Gateway): + +```json +{ + "ExternalSystems": { + "TestApi": { + "BaseUrl": "http://localhost:5200", + "AuthMode": "ApiKey", + "ApiKey": "scadalink-test-key-1" + } + } +} +``` + +## Endpoints + +### Simple Methods + +| Method | Path | HTTP | Params | Response | Description | +|--------|------|------|--------|----------|-------------| +| Ping | `/api/Ping` | GET | — | `{"pong": true}` | Health check (no auth) | +| Add | `/api/Add` | POST | `{"a": 5, "b": 3}` | `{"result": 8}` | Add two numbers | +| Multiply | `/api/Multiply` | POST | `{"a": 4, "b": 7}` | `{"result": 28}` | Multiply two numbers | +| Echo | `/api/Echo` | POST | `{"message": "hello"}` | `{"message": "hello"}` | Echo back input | +| GetStatus | `/api/GetStatus` | POST | `{}` | `{"status": "running", "uptime": 123.4}` | Server status | + +### Complex Methods (nested objects + lists) + +| Method | Path | HTTP | Description | +|--------|------|------|-------------| +| GetProductionReport | `/api/GetProductionReport` | POST | Production report with line details | +| GetRecipe | `/api/GetRecipe` | POST | Recipe with ingredients list | +| SubmitBatch | `/api/SubmitBatch` | POST | Submit batch with items (complex input) | +| GetEquipmentStatus | `/api/GetEquipmentStatus` | POST | Equipment list with nested status objects | + +### Error Simulation + +| Method | Path | HTTP | Params | Description | +|--------|------|------|--------|-------------| +| SimulateTimeout | `/api/SimulateTimeout` | POST | `{"seconds": 5}` | Delay response (max 60s) | +| SimulateError | `/api/SimulateError` | POST | `{"code": 500}` | Return specified HTTP error (400–599) | + +### Method Discovery + +| Method | Path | HTTP | Description | +|--------|------|------|-------------| +| methods | `/api/methods` | GET | List all available methods with signatures | + +## Response Examples + +**GetProductionReport** (`{"siteId": "SiteA", "startDate": "2026-03-01", "endDate": "2026-03-16"}`): + +```json +{ + "siteName": "Site SiteA", + "totalUnits": 14250, + "lines": [ + { "lineName": "Line-1", "units": 8200, "efficiency": 92.5 }, + { "lineName": "Line-2", "units": 6050, "efficiency": 88.1 } + ] +} +``` + +**GetRecipe** (`{"recipeId": "R-100"}`): + +```json +{ + "recipeId": "R-100", + "name": "Standard Mix", + "version": 3, + "ingredients": [ + { "name": "Material-A", "quantity": 45.0, "unit": "kg" }, + { "name": "Material-B", "quantity": 12.5, "unit": "L" } + ] +} +``` + +**SubmitBatch** (complex input with items list): + +```json +{ + "siteId": "SiteA", + "recipeId": "R-100", + "items": [ + { "materialId": "MAT-001", "quantity": 45.0, "lotNumber": "LOT-2026-001" }, + { "materialId": "MAT-002", "quantity": 12.5, "lotNumber": "LOT-2026-002" } + ] +} +``` + +Response: `{"batchId": "BATCH-A1B2C3D4", "accepted": true, "itemCount": 2}` + +**GetEquipmentStatus** (`{"siteId": "SiteA"}`): + +```json +{ + "siteId": "SiteA", + "equipment": [ + { + "equipmentId": "PUMP-001", + "name": "Feed Pump A", + "status": { "state": "running", "health": 98.5, "lastMaintenance": "2026-02-15" } + }, + { + "equipmentId": "TANK-001", + "name": "Mix Tank 1", + "status": { "state": "idle", "health": 100.0, "lastMaintenance": "2026-03-01" } + }, + { + "equipmentId": "CONV-001", + "name": "Conveyor B", + "status": { "state": "alarm", "health": 72.3, "lastMaintenance": "2026-01-20" } + } + ] +} +``` + +## Verification + +1. Check the container is running: + +```bash +docker ps --filter name=scadalink-restapi +``` + +2. Test the health endpoint: + +```bash +curl http://localhost:5200/api/Ping +``` + +3. Test an authenticated call: + +```bash +curl -X POST http://localhost:5200/api/Add \ + -H "X-API-Key: scadalink-test-key-1" \ + -H "Content-Type: application/json" \ + -d '{"a": 2, "b": 3}' +``` + +## CLI Tool + +The `infra/tools/restapi_tool.py` script provides a CLI for interacting with the REST API server. This tool requires the `requests` library (included in `tools/requirements.txt`). + +**Commands**: + +```bash +# Check API server connectivity and status +python infra/tools/restapi_tool.py check + +# Call a simple method +python infra/tools/restapi_tool.py call --method Add --params '{"a": 2, "b": 3}' + +# Call a complex method +python infra/tools/restapi_tool.py call --method GetProductionReport --params '{"siteId": "SiteA", "startDate": "2026-03-01", "endDate": "2026-03-16"}' + +# Simulate an error +python infra/tools/restapi_tool.py call --method SimulateError --params '{"code": 503}' + +# List all available methods +python infra/tools/restapi_tool.py methods +``` + +Use `--url` to override the base URL (default: `http://localhost:5200`), `--api-key` for the API key. Run with `--help` for full usage. + +## Relevance to ScadaLink Components + +- **External System Gateway** — test HTTP/REST calls (`ExternalSystem.Call()` and `CachedCall()`), API key authentication, error classification (5xx vs 4xx), and timeout handling. +- **Inbound API** — test the `POST /api/{methodName}` pattern, flat JSON parameters, and extended type system (Object, List) with complex nested responses. +- **Store-and-Forward Engine** — verify buffered retry by using `SimulateError` to return transient errors (503, 408, 429) and observing store-and-forward behavior. + +## Notes + +- The server is stateless — no data persistence between container restarts. +- `SimulateTimeout` caps at 60 seconds to prevent accidental container hangs. +- `SimulateError` accepts codes 400–599; other values default to 500. +- `SubmitBatch` generates a unique `batchId` per call (UUID-based). +- The `/api/methods` endpoint provides machine-readable method discovery (useful for CLI tool and automated testing). +- To simulate connection failures for store-and-forward testing, stop the container: `docker compose stop restapi`. Restart with `docker compose start restapi`.