Add Flask REST API test server for External System Gateway and Inbound API testing
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
6
infra/restapi/Dockerfile
Normal 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
194
infra/restapi/app.py
Normal 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)
|
||||
@@ -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
152
infra/tools/restapi_tool.py
Normal 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()
|
||||
@@ -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
|
||||
```
|
||||
|
||||
208
test_infra_restapi.md
Normal file
208
test_infra_restapi.md
Normal file
@@ -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`.
|
||||
Reference in New Issue
Block a user