Add request validation and OpenAPI generation
This commit is contained in:
@@ -42,6 +42,8 @@ Environment is centralized in `src/config.ts` via `getConfig()`.
|
|||||||
- The API is a thin adapter: it shapes responses, validates inputs, and delegates business logic to `blackroad-os-operator` and `blackroad-os-core` when available.
|
- The API is a thin adapter: it shapes responses, validates inputs, and delegates business logic to `blackroad-os-operator` and `blackroad-os-core` when available.
|
||||||
- RoadChain and some finance data are mocked for now; TODO markers indicate where to swap in real upstream calls.
|
- RoadChain and some finance data are mocked for now; TODO markers indicate where to swap in real upstream calls.
|
||||||
- Responses always follow the `{ ok: boolean; data?; error? }` envelope to keep Prism and other clients stable.
|
- Responses always follow the `{ ok: boolean; data?; error? }` envelope to keep Prism and other clients stable.
|
||||||
|
- Requests are validated with Zod via `validateRequest`; invalid params return `{ ok: false, error: { code: "INVALID_REQUEST" } }`.
|
||||||
|
- Run `npm run generate:openapi` to produce `docs/openapi.generated.json` from the runtime schemas.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
838
docs/openapi.generated.json
Normal file
838
docs/openapi.generated.json
Normal file
@@ -0,0 +1,838 @@
|
|||||||
|
{
|
||||||
|
"openapi": "3.0.3",
|
||||||
|
"info": {
|
||||||
|
"title": "BlackRoad OS API",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"description": "Typed HTTP surface for BlackRoad OS"
|
||||||
|
},
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:4000",
|
||||||
|
"description": "Local development"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://api.blackroad.systems",
|
||||||
|
"description": "Production"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"components": {
|
||||||
|
"schemas": {
|
||||||
|
"ApiError": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"ok": {
|
||||||
|
"type": "boolean",
|
||||||
|
"enum": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"code": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"details": {
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"code",
|
||||||
|
"message"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"ok",
|
||||||
|
"error"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Agent": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"idle",
|
||||||
|
"running",
|
||||||
|
"error",
|
||||||
|
"offline"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"lastHeartbeat": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"role",
|
||||||
|
"status",
|
||||||
|
"lastHeartbeat"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ServiceHealth": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"healthy",
|
||||||
|
"degraded",
|
||||||
|
"down"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"latencyMs": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"lastChecked": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"status",
|
||||||
|
"lastChecked"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"HealthResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"overallStatus": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"healthy",
|
||||||
|
"degraded",
|
||||||
|
"down"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"healthy",
|
||||||
|
"degraded",
|
||||||
|
"down"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"latencyMs": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"lastChecked": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"status",
|
||||||
|
"lastChecked"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"overallStatus",
|
||||||
|
"services"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"SystemOverview": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"overallStatus": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"healthy",
|
||||||
|
"degraded",
|
||||||
|
"down"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"healthy",
|
||||||
|
"degraded",
|
||||||
|
"down"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"latencyMs": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"lastChecked": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"status",
|
||||||
|
"lastChecked"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"jobsProcessedLast24h": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"errorsLast24h": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"overallStatus",
|
||||||
|
"services"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"FinanceSnapshot": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"timestamp": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"monthlyInfraCostUsd": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"monthlyRevenueUsd": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"estimatedSavingsUsd": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"walletBalanceUsd": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"timestamp"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"FinanceSummary": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"currency": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"cashBalance": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"monthlyBurnRate": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"runwayMonths": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"mrr": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"arr": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"generatedAt": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"currency",
|
||||||
|
"cashBalance",
|
||||||
|
"monthlyBurnRate",
|
||||||
|
"runwayMonths",
|
||||||
|
"generatedAt"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"CashForecast": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"currency": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"buckets": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"startDate": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"endDate": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"netChange": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"endingBalance": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"startDate",
|
||||||
|
"endDate",
|
||||||
|
"netChange",
|
||||||
|
"endingBalance"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"generatedAt": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"currency",
|
||||||
|
"buckets",
|
||||||
|
"generatedAt"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"EventRecord": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"timestamp": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"psShaInfinity": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"severity": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"info",
|
||||||
|
"warning",
|
||||||
|
"error"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"timestamp",
|
||||||
|
"source",
|
||||||
|
"type",
|
||||||
|
"summary"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"RoadChainBlock": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"height": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"hash": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"prevHash": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"timestamp": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"eventIds": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"height",
|
||||||
|
"hash",
|
||||||
|
"prevHash",
|
||||||
|
"timestamp",
|
||||||
|
"eventIds"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"parameters": {}
|
||||||
|
},
|
||||||
|
"paths": {
|
||||||
|
"/api/v1/health": {
|
||||||
|
"get": {
|
||||||
|
"description": "API and dependency health",
|
||||||
|
"tags": [
|
||||||
|
"health"
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Aggregated service health",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"ok": {
|
||||||
|
"type": "boolean",
|
||||||
|
"enum": [
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"$ref": "#/components/schemas/HealthResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"ok",
|
||||||
|
"data"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/system/overview": {
|
||||||
|
"get": {
|
||||||
|
"description": "System overview",
|
||||||
|
"tags": [
|
||||||
|
"system"
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Overall system overview",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"ok": {
|
||||||
|
"type": "boolean",
|
||||||
|
"enum": [
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"$ref": "#/components/schemas/SystemOverview"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"ok",
|
||||||
|
"data"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/agents": {
|
||||||
|
"get": {
|
||||||
|
"description": "List agents with optional filters",
|
||||||
|
"tags": [
|
||||||
|
"agents"
|
||||||
|
],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"idle",
|
||||||
|
"running",
|
||||||
|
"error",
|
||||||
|
"offline"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"required": false,
|
||||||
|
"name": "status",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 120
|
||||||
|
},
|
||||||
|
"required": false,
|
||||||
|
"name": "q",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Agents matching filters",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"ok": {
|
||||||
|
"type": "boolean",
|
||||||
|
"enum": [
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/Agent"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"ok",
|
||||||
|
"data"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid filters",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/agents/{id}": {
|
||||||
|
"get": {
|
||||||
|
"description": "Fetch a single agent by ID",
|
||||||
|
"tags": [
|
||||||
|
"agents"
|
||||||
|
],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"required": true,
|
||||||
|
"name": "id",
|
||||||
|
"in": "path"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Agent found",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"ok": {
|
||||||
|
"type": "boolean",
|
||||||
|
"enum": [
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"$ref": "#/components/schemas/Agent"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"ok",
|
||||||
|
"data"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Agent not found",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/finance/snapshot": {
|
||||||
|
"get": {
|
||||||
|
"description": "Finance snapshot",
|
||||||
|
"tags": [
|
||||||
|
"finance"
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Finance snapshot",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"ok": {
|
||||||
|
"type": "boolean",
|
||||||
|
"enum": [
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"$ref": "#/components/schemas/FinanceSnapshot"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"ok",
|
||||||
|
"data"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/finance/summary": {
|
||||||
|
"get": {
|
||||||
|
"description": "Finance summary",
|
||||||
|
"tags": [
|
||||||
|
"finance"
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Finance summary",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"ok": {
|
||||||
|
"type": "boolean",
|
||||||
|
"enum": [
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"$ref": "#/components/schemas/FinanceSummary"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"ok",
|
||||||
|
"data"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/finance/cash-forecast": {
|
||||||
|
"get": {
|
||||||
|
"description": "Cash forecast",
|
||||||
|
"tags": [
|
||||||
|
"finance"
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Cash forecast",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"ok": {
|
||||||
|
"type": "boolean",
|
||||||
|
"enum": [
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"$ref": "#/components/schemas/CashForecast"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"ok",
|
||||||
|
"data"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/events": {
|
||||||
|
"get": {
|
||||||
|
"description": "Event feed with filters",
|
||||||
|
"tags": [
|
||||||
|
"events"
|
||||||
|
],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 200,
|
||||||
|
"default": 50
|
||||||
|
},
|
||||||
|
"required": false,
|
||||||
|
"name": "limit",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"info",
|
||||||
|
"warning",
|
||||||
|
"error"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"required": false,
|
||||||
|
"name": "severity",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 64
|
||||||
|
},
|
||||||
|
"required": false,
|
||||||
|
"name": "source",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Filtered events",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"ok": {
|
||||||
|
"type": "boolean",
|
||||||
|
"enum": [
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/EventRecord"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"ok",
|
||||||
|
"data"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid filters",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/roadchain/blocks": {
|
||||||
|
"get": {
|
||||||
|
"description": "RoadChain block headers",
|
||||||
|
"tags": [
|
||||||
|
"roadchain"
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Recent blocks",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"ok": {
|
||||||
|
"type": "boolean",
|
||||||
|
"enum": [
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/RoadChainBlock"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"ok",
|
||||||
|
"data"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
47
package-lock.json
generated
47
package-lock.json
generated
@@ -8,10 +8,12 @@
|
|||||||
"name": "blackroad-os-public-api",
|
"name": "blackroad-os-public-api",
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@asteasolutions/zod-to-openapi": "^8.1.0",
|
||||||
"axios": "^1.7.4",
|
"axios": "^1.7.4",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.19.2"
|
"express": "^4.19.2",
|
||||||
|
"zod": "^4.1.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
@@ -22,10 +24,23 @@
|
|||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"supertest": "^6.3.4",
|
"supertest": "^6.3.4",
|
||||||
"ts-jest": "^29.1.1",
|
"ts-jest": "^29.1.1",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
"ts-node-dev": "^2.0.0",
|
"ts-node-dev": "^2.0.0",
|
||||||
"typescript": "^5.4.5"
|
"typescript": "^5.4.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@asteasolutions/zod-to-openapi": {
|
||||||
|
"version": "8.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@asteasolutions/zod-to-openapi/-/zod-to-openapi-8.1.0.tgz",
|
||||||
|
"integrity": "sha512-tQFxVs05J/6QXXqIzj6rTRk3nj1HFs4pe+uThwE95jL5II2JfpVXkK+CqkO7aT0Do5AYqO6LDrKpleLUFXgY+g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"openapi3-ts": "^4.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.27.1",
|
"version": "7.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||||
@@ -4172,6 +4187,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/openapi3-ts": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-jaL+HgTq2Gj5jRcfdutgRGLosCy/hT8sQf6VOy+P+g36cZOjI1iukdPnijC+4CmeRzg/jEllJUboEic2FhxhtQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"yaml": "^2.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/p-limit": {
|
"node_modules/p-limit": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||||
@@ -5506,6 +5530,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/yaml": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"yaml": "bin.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yargs": {
|
"node_modules/yargs": {
|
||||||
"version": "17.7.2",
|
"version": "17.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||||
@@ -5557,6 +5593,15 @@
|
|||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zod": {
|
||||||
|
"version": "4.1.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz",
|
||||||
|
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,16 @@
|
|||||||
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
|
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"test": "NODE_ENV=test jest"
|
"test": "NODE_ENV=test jest",
|
||||||
|
"generate:openapi": "ts-node scripts/generate-openapi.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@asteasolutions/zod-to-openapi": "^8.1.0",
|
||||||
"axios": "^1.7.4",
|
"axios": "^1.7.4",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.19.2"
|
"express": "^4.19.2",
|
||||||
|
"zod": "^4.1.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
@@ -24,6 +27,7 @@
|
|||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"supertest": "^6.3.4",
|
"supertest": "^6.3.4",
|
||||||
"ts-jest": "^29.1.1",
|
"ts-jest": "^29.1.1",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
"ts-node-dev": "^2.0.0",
|
"ts-node-dev": "^2.0.0",
|
||||||
"typescript": "^5.4.5"
|
"typescript": "^5.4.5"
|
||||||
}
|
}
|
||||||
|
|||||||
9
scripts/generate-openapi.ts
Normal file
9
scripts/generate-openapi.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { writeFileSync } from "fs";
|
||||||
|
import { resolve } from "path";
|
||||||
|
import { buildOpenAPIDocument } from "../src/openapi";
|
||||||
|
|
||||||
|
const outputPath = resolve(__dirname, "../docs/openapi.generated.json");
|
||||||
|
const document = buildOpenAPIDocument();
|
||||||
|
|
||||||
|
writeFileSync(outputPath, JSON.stringify(document, null, 2));
|
||||||
|
console.log(`OpenAPI spec written to ${outputPath}`);
|
||||||
51
src/middleware/validateRequest.ts
Normal file
51
src/middleware/validateRequest.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { NextFunction, Request, RequestHandler, Response } from "express";
|
||||||
|
import { ZodError, ZodIssue, ZodTypeAny } from "zod";
|
||||||
|
import { ApiRouteError } from "./errorHandler";
|
||||||
|
|
||||||
|
interface RequestSchemas {
|
||||||
|
body?: ZodTypeAny;
|
||||||
|
query?: ZodTypeAny;
|
||||||
|
params?: ZodTypeAny;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatZodError(error: ZodError) {
|
||||||
|
return error.issues.map((issue: ZodIssue) => ({
|
||||||
|
path: issue.path.join("."),
|
||||||
|
message: issue.message,
|
||||||
|
code: issue.code,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateRequest(schemas: RequestSchemas): RequestHandler {
|
||||||
|
return (req: Request, _res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
if (schemas.query) {
|
||||||
|
const parsedQuery = schemas.query.parse(req.query);
|
||||||
|
req.query = parsedQuery as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schemas.params) {
|
||||||
|
const parsedParams = schemas.params.parse(req.params);
|
||||||
|
req.params = parsedParams as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schemas.body) {
|
||||||
|
const parsedBody = schemas.body.parse(req.body);
|
||||||
|
req.body = parsedBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
const apiError: ApiRouteError = new Error("Invalid request");
|
||||||
|
apiError.statusCode = 400;
|
||||||
|
apiError.code = "INVALID_REQUEST";
|
||||||
|
|
||||||
|
if (err instanceof ZodError) {
|
||||||
|
apiError.details = formatZodError(err);
|
||||||
|
apiError.message = "Request validation failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
next(apiError);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
193
src/openapi/index.ts
Normal file
193
src/openapi/index.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import { OpenApiGeneratorV3, OpenAPIRegistry } from "@asteasolutions/zod-to-openapi";
|
||||||
|
import {
|
||||||
|
ApiErrorSchema,
|
||||||
|
agentIdParamsSchema,
|
||||||
|
agentListQuerySchema,
|
||||||
|
agentSchema,
|
||||||
|
apiSuccessSchema,
|
||||||
|
cashForecastSchema,
|
||||||
|
eventRecordSchema,
|
||||||
|
eventsQuerySchema,
|
||||||
|
healthResponseSchema,
|
||||||
|
financeSnapshotSchema,
|
||||||
|
financeSummarySchema,
|
||||||
|
roadChainBlockSchema,
|
||||||
|
serviceHealthSchema,
|
||||||
|
systemOverviewSchema,
|
||||||
|
} from "../validation/schemas";
|
||||||
|
|
||||||
|
function jsonResponse(schema: any) {
|
||||||
|
return {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOpenAPIDocument() {
|
||||||
|
const registry = new OpenAPIRegistry();
|
||||||
|
|
||||||
|
const ApiError = registry.register("ApiError", ApiErrorSchema);
|
||||||
|
const Agent = registry.register("Agent", agentSchema);
|
||||||
|
const ServiceHealth = registry.register("ServiceHealth", serviceHealthSchema);
|
||||||
|
const HealthResponse = registry.register("HealthResponse", healthResponseSchema);
|
||||||
|
const SystemOverview = registry.register("SystemOverview", systemOverviewSchema);
|
||||||
|
const FinanceSnapshot = registry.register("FinanceSnapshot", financeSnapshotSchema);
|
||||||
|
const FinanceSummary = registry.register("FinanceSummary", financeSummarySchema);
|
||||||
|
const CashForecast = registry.register("CashForecast", cashForecastSchema);
|
||||||
|
const EventRecord = registry.register("EventRecord", eventRecordSchema);
|
||||||
|
const RoadChainBlock = registry.register("RoadChainBlock", roadChainBlockSchema);
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get",
|
||||||
|
path: "/api/v1/health",
|
||||||
|
description: "API and dependency health",
|
||||||
|
tags: ["health"],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Aggregated service health",
|
||||||
|
...jsonResponse(apiSuccessSchema(HealthResponse)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get",
|
||||||
|
path: "/api/v1/system/overview",
|
||||||
|
description: "System overview",
|
||||||
|
tags: ["system"],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Overall system overview",
|
||||||
|
...jsonResponse(apiSuccessSchema(SystemOverview)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get",
|
||||||
|
path: "/api/v1/agents",
|
||||||
|
description: "List agents with optional filters",
|
||||||
|
tags: ["agents"],
|
||||||
|
request: {
|
||||||
|
query: agentListQuerySchema,
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Agents matching filters",
|
||||||
|
...jsonResponse(apiSuccessSchema(Agent.array())),
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
description: "Invalid filters",
|
||||||
|
...jsonResponse(ApiError),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get",
|
||||||
|
path: "/api/v1/agents/{id}",
|
||||||
|
description: "Fetch a single agent by ID",
|
||||||
|
tags: ["agents"],
|
||||||
|
request: {
|
||||||
|
params: agentIdParamsSchema,
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Agent found",
|
||||||
|
...jsonResponse(apiSuccessSchema(Agent)),
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
description: "Agent not found",
|
||||||
|
...jsonResponse(ApiError),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get",
|
||||||
|
path: "/api/v1/finance/snapshot",
|
||||||
|
description: "Finance snapshot",
|
||||||
|
tags: ["finance"],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Finance snapshot",
|
||||||
|
...jsonResponse(apiSuccessSchema(FinanceSnapshot)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get",
|
||||||
|
path: "/api/v1/finance/summary",
|
||||||
|
description: "Finance summary",
|
||||||
|
tags: ["finance"],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Finance summary",
|
||||||
|
...jsonResponse(apiSuccessSchema(FinanceSummary)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get",
|
||||||
|
path: "/api/v1/finance/cash-forecast",
|
||||||
|
description: "Cash forecast",
|
||||||
|
tags: ["finance"],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Cash forecast",
|
||||||
|
...jsonResponse(apiSuccessSchema(CashForecast)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get",
|
||||||
|
path: "/api/v1/events",
|
||||||
|
description: "Event feed with filters",
|
||||||
|
tags: ["events"],
|
||||||
|
request: { query: eventsQuerySchema },
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Filtered events",
|
||||||
|
...jsonResponse(apiSuccessSchema(EventRecord.array())),
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
description: "Invalid filters",
|
||||||
|
...jsonResponse(ApiError),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get",
|
||||||
|
path: "/api/v1/roadchain/blocks",
|
||||||
|
description: "RoadChain block headers",
|
||||||
|
tags: ["roadchain"],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Recent blocks",
|
||||||
|
...jsonResponse(apiSuccessSchema(RoadChainBlock.array())),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const generator = new OpenApiGeneratorV3(registry.definitions);
|
||||||
|
|
||||||
|
return generator.generateDocument({
|
||||||
|
openapi: "3.0.3",
|
||||||
|
info: {
|
||||||
|
title: "BlackRoad OS API",
|
||||||
|
version: "0.2.0",
|
||||||
|
description: "Typed HTTP surface for BlackRoad OS",
|
||||||
|
},
|
||||||
|
servers: [
|
||||||
|
{ url: "http://localhost:4000", description: "Local development" },
|
||||||
|
{ url: "https://api.blackroad.systems", description: "Production" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,18 +1,28 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { fetchAgentById, fetchAgents } from "../clients/operatorClient";
|
import { fetchAgentById, fetchAgents } from "../clients/operatorClient";
|
||||||
import { ApiRouteError } from "../middleware/errorHandler";
|
import { ApiRouteError } from "../middleware/errorHandler";
|
||||||
|
import { validateRequest } from "../middleware/validateRequest";
|
||||||
import { Agent, ApiResponse } from "../types/api";
|
import { Agent, ApiResponse } from "../types/api";
|
||||||
|
import {
|
||||||
|
AgentIdParams,
|
||||||
|
AgentListQuery,
|
||||||
|
agentIdParamsSchema,
|
||||||
|
agentListQuerySchema,
|
||||||
|
} from "../validation/schemas";
|
||||||
|
|
||||||
export function createAgentsRouter() {
|
export function createAgentsRouter() {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.get("/", async (req, res, next) => {
|
router.get(
|
||||||
|
"/",
|
||||||
|
validateRequest({ query: agentListQuerySchema }),
|
||||||
|
async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { status, q } = req.query;
|
const { status, q } = req.query as AgentListQuery;
|
||||||
const agents = await fetchAgents();
|
const agents = await fetchAgents();
|
||||||
const filtered = agents.filter((agent) => {
|
const filtered = agents.filter((agent) => {
|
||||||
const statusMatch = status ? agent.status === status : true;
|
const statusMatch = status ? agent.status === status : true;
|
||||||
const query = (q as string | undefined)?.toLowerCase();
|
const query = q?.toLowerCase();
|
||||||
const queryMatch = query
|
const queryMatch = query
|
||||||
? agent.name.toLowerCase().includes(query) ||
|
? agent.name.toLowerCase().includes(query) ||
|
||||||
(agent.tags || []).some((tag) => tag.toLowerCase().includes(query))
|
(agent.tags || []).some((tag) => tag.toLowerCase().includes(query))
|
||||||
@@ -25,11 +35,16 @@ export function createAgentsRouter() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
router.get("/:id", async (req, res, next) => {
|
router.get(
|
||||||
|
"/:id",
|
||||||
|
validateRequest({ params: agentIdParamsSchema }),
|
||||||
|
async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const agent = await fetchAgentById(req.params.id);
|
const { id } = req.params as AgentIdParams;
|
||||||
|
const agent = await fetchAgentById(id);
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
const error: ApiRouteError = new Error("Agent not found");
|
const error: ApiRouteError = new Error("Agent not found");
|
||||||
error.statusCode = 404;
|
error.statusCode = 404;
|
||||||
@@ -42,7 +57,8 @@ export function createAgentsRouter() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
|
import { validateRequest } from "../middleware/validateRequest";
|
||||||
import { ApiResponse, EventRecord } from "../types/api";
|
import { ApiResponse, EventRecord } from "../types/api";
|
||||||
|
import { EventsQuery, eventsQuerySchema } from "../validation/schemas";
|
||||||
|
|
||||||
const MOCK_EVENTS: EventRecord[] = [
|
const MOCK_EVENTS: EventRecord[] = [
|
||||||
{
|
{
|
||||||
@@ -32,18 +34,21 @@ const MOCK_EVENTS: EventRecord[] = [
|
|||||||
export function createEventsRouter() {
|
export function createEventsRouter() {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.get("/", (req, res) => {
|
router.get(
|
||||||
const { limit = "50", severity, source } = req.query;
|
"/",
|
||||||
const parsedLimit = Math.max(1, Math.min(Number(limit) || 50, 200));
|
validateRequest({ query: eventsQuerySchema }),
|
||||||
|
(req, res) => {
|
||||||
|
const { limit, severity, source } = req.query as unknown as EventsQuery;
|
||||||
const filtered = MOCK_EVENTS.filter((event) => {
|
const filtered = MOCK_EVENTS.filter((event) => {
|
||||||
const severityMatch = severity ? event.severity === severity : true;
|
const severityMatch = severity ? event.severity === severity : true;
|
||||||
const sourceMatch = source ? event.source === source : true;
|
const sourceMatch = source ? event.source === source : true;
|
||||||
return severityMatch && sourceMatch;
|
return severityMatch && sourceMatch;
|
||||||
}).slice(0, parsedLimit);
|
}).slice(0, limit ?? 50);
|
||||||
|
|
||||||
const response: ApiResponse<EventRecord[]> = { ok: true, data: filtered };
|
const response: ApiResponse<EventRecord[]> = { ok: true, data: filtered };
|
||||||
res.json(response);
|
res.json(response);
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
137
src/validation/schemas.ts
Normal file
137
src/validation/schemas.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
extendZodWithOpenApi(z);
|
||||||
|
|
||||||
|
export const ApiErrorSchema = z.object({
|
||||||
|
ok: z.literal(false),
|
||||||
|
error: z.object({
|
||||||
|
code: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
details: z.unknown().optional(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const apiSuccessSchema = <T extends z.ZodTypeAny>(schema: T) =>
|
||||||
|
z.object({
|
||||||
|
ok: z.literal(true),
|
||||||
|
data: schema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const serviceHealthSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
status: z.enum(["healthy", "degraded", "down"]),
|
||||||
|
latencyMs: z.number().optional(),
|
||||||
|
lastChecked: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const healthResponseSchema = z.object({
|
||||||
|
overallStatus: z.enum(["healthy", "degraded", "down"]),
|
||||||
|
services: z.array(serviceHealthSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const systemOverviewSchema = z.object({
|
||||||
|
overallStatus: z.enum(["healthy", "degraded", "down"]),
|
||||||
|
services: z.array(serviceHealthSchema),
|
||||||
|
jobsProcessedLast24h: z.number().optional(),
|
||||||
|
errorsLast24h: z.number().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const agentStatusSchema = z.enum(["idle", "running", "error", "offline"]);
|
||||||
|
|
||||||
|
export const agentSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
role: z.string(),
|
||||||
|
status: agentStatusSchema,
|
||||||
|
lastHeartbeat: z.string(),
|
||||||
|
version: z.string().optional(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const agentListQuerySchema = z
|
||||||
|
.object({
|
||||||
|
status: agentStatusSchema.optional(),
|
||||||
|
q: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, "Query must be at least 1 character")
|
||||||
|
.max(120, "Query must be 120 characters or fewer")
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const agentIdParamsSchema = z.object({
|
||||||
|
id: z.string().trim().min(1, "Agent id cannot be empty"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const financeSnapshotSchema = z.object({
|
||||||
|
timestamp: z.string(),
|
||||||
|
monthlyInfraCostUsd: z.number().optional(),
|
||||||
|
monthlyRevenueUsd: z.number().optional(),
|
||||||
|
estimatedSavingsUsd: z.number().optional(),
|
||||||
|
walletBalanceUsd: z.number().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const financeSummarySchema = z.object({
|
||||||
|
currency: z.string(),
|
||||||
|
cashBalance: z.number(),
|
||||||
|
monthlyBurnRate: z.number(),
|
||||||
|
runwayMonths: z.number(),
|
||||||
|
mrr: z.number().optional(),
|
||||||
|
arr: z.number().optional(),
|
||||||
|
generatedAt: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const cashForecastBucketSchema = z.object({
|
||||||
|
startDate: z.string(),
|
||||||
|
endDate: z.string(),
|
||||||
|
netChange: z.number(),
|
||||||
|
endingBalance: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const cashForecastSchema = z.object({
|
||||||
|
currency: z.string(),
|
||||||
|
buckets: z.array(cashForecastBucketSchema),
|
||||||
|
generatedAt: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const eventSeveritySchema = z.enum(["info", "warning", "error"]);
|
||||||
|
|
||||||
|
export const eventRecordSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
timestamp: z.string(),
|
||||||
|
source: z.string(),
|
||||||
|
type: z.string(),
|
||||||
|
summary: z.string(),
|
||||||
|
psShaInfinity: z.string().optional(),
|
||||||
|
severity: eventSeveritySchema.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const eventsQuerySchema = z
|
||||||
|
.object({
|
||||||
|
limit: z.coerce.number().int().min(1).max(200).default(50),
|
||||||
|
severity: eventSeveritySchema.optional(),
|
||||||
|
source: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, "Source must be at least 1 character")
|
||||||
|
.max(64, "Source must be 64 characters or fewer")
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const roadChainBlockSchema = z.object({
|
||||||
|
height: z.number(),
|
||||||
|
hash: z.string(),
|
||||||
|
prevHash: z.string(),
|
||||||
|
timestamp: z.string(),
|
||||||
|
eventIds: z.array(z.string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AgentListQuery = z.infer<typeof agentListQuerySchema>;
|
||||||
|
export type AgentIdParams = z.infer<typeof agentIdParamsSchema>;
|
||||||
|
export type EventsQuery = z.infer<typeof eventsQuerySchema>;
|
||||||
30
tests/validation.test.ts
Normal file
30
tests/validation.test.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import request from "supertest";
|
||||||
|
import { createApp } from "../src/server";
|
||||||
|
|
||||||
|
const app = createApp();
|
||||||
|
|
||||||
|
describe("request validation", () => {
|
||||||
|
it("rejects unknown agent status filters", async () => {
|
||||||
|
const res = await request(app).get("/api/v1/agents?status=bogus").expect(400);
|
||||||
|
|
||||||
|
expect(res.body.ok).toBe(false);
|
||||||
|
expect(res.body.error.code).toBe("INVALID_REQUEST");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps and defaults events limit", async () => {
|
||||||
|
const res = await request(app).get("/api/v1/events?limit=1").expect(200);
|
||||||
|
|
||||||
|
expect(res.body.ok).toBe(true);
|
||||||
|
expect(res.body.data.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid event severity", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.get("/api/v1/events?severity=critical")
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
expect(res.body.ok).toBe(false);
|
||||||
|
expect(res.body.error.code).toBe("INVALID_REQUEST");
|
||||||
|
expect(res.body.error.details?.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user