Add Flask REST API test server for External System Gateway and Inbound API testing

This commit is contained in:
Joseph Doherty
2026-03-16 14:28:03 -04:00
parent 40610271d6
commit 0513a104a9
8 changed files with 593 additions and 7 deletions

View File

@@ -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)

View File

@@ -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:

6
infra/restapi/Dockerfile Normal file
View File

@@ -0,0 +1,6 @@
FROM python:3.13-slim
WORKDIR /app
RUN pip install flask
COPY app.py .
EXPOSE 5200
CMD ["python", "app.py"]

194
infra/restapi/app.py Normal file
View File

@@ -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)

View File

@@ -1,3 +1,4 @@
opcua>=0.98.0
ldap3>=2.9
pymssql>=2.2.0
requests>=2.31.0

152
infra/tools/restapi_tool.py Normal file
View File

@@ -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()