API Integration¶
This document describes how the Vayu Manager communicates with the Vayu Engine (C++ daemon) via HTTP.
Overview¶
The app communicates with the engine through: - HTTP REST API: For CRUD operations and request execution - Server-Sent Events (SSE): For real-time load test metrics
All communication happens on localhost:9876 (configurable).
API Client Architecture¶
┌─────────────────────────────────────────┐
│ React Components │
│ (RequestBuilder, Dashboard, etc.) │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ Custom Hooks │
│ (useEngine, useSSE) │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ Services Layer │
│ - api.ts (HTTP client) │
│ - sse-client.ts (SSE client) │
│ - http-client.ts (fetch wrapper) │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ Vayu Engine │
│ (localhost:9876) │
└─────────────────────────────────────────┘
HTTP Client (services/http-client.ts)¶
Low-level fetch wrapper with error handling and timeout management.
Features¶
- Base URL Configuration:
http://127.0.0.1:9876(fromconfig/api-endpoints.ts) - Request Timeout: 30 seconds default
- Error Transformation: Converts HTTP errors to
ApiErrorwith user-friendly messages - Query Parameters: Automatic URL encoding
- JSON Serialization: Automatic request/response JSON handling
Error Handling¶
class ApiError extends Error {
statusCode: number
errorCode: string
userFriendlyMessage: string
response?: any
}
Error Types:
- isTimeout: Request timeout
- isNetworkError: Connection/DNS errors
- isDatabaseError: Engine database errors
API Service (services/api.ts)¶
High-level service layer that wraps HTTP client with domain-specific methods.
Data Transformation¶
The service handles transformation between frontend (snake_case) and backend (camelCase) formats:
Frontend Format (snake_case):
{
collection_id: "col_123"
created_at: "2024-01-01T00:00:00Z"
pre_request_script: "console.log('test')"
}
Backend Format (camelCase):
API Methods¶
Health & Configuration¶
apiService.getHealth(): Promise<EngineHealth>
apiService.getConfig(): Promise<EngineConfig>
apiService.updateConfig(config): Promise<EngineConfig>
Create vs update¶
For collections, requests and environments the engine splits the write verbs:
POST /<resource> creates and answers a known id with 409;
PUT /<resource>/:id updates and answers an unknown id with 404. They are
not interchangeable, so apiService keeps one method per verb:
createX(data)posts the whole object to the collection path.datacarries noidfor an interactive create; the import path is the one caller that still supplies its ownid, because it pre-assigns ids to wireparentId/collectionIdacross the tree before anything is persisted.updateX(data)takesdata.id, puts it in the path, and sends the rest of the object as a merge-patch body - an omitted field keeps its stored value, an explicitnullresets it to the default. Theidis not repeated in the body.
The full contract, including the null-vs-absent table and which fields have no
default, is in engine/api-reference.md under
"Resource writes". src/services/api.write-verbs.test.ts pins the method and
path of every one of these calls - a regression here is invisible at every other
layer, because the payload shape does not change.
Collections¶
apiService.listCollections(): Promise<Collection[]>
apiService.createCollection(data): Promise<Collection> // POST /collections
apiService.updateCollection(data): Promise<Collection> // PUT /collections/:id
apiService.deleteCollection(id): Promise<void>
Requests¶
apiService.listRequests(params?): Promise<Request[]>
apiService.getRequest(id): Promise<Request>
apiService.createRequest(data): Promise<Request> // POST /requests
apiService.updateRequest(data): Promise<Request> // PUT /requests/:id
apiService.deleteRequest(id): Promise<void>
Environments¶
apiService.listEnvironments(): Promise<Environment[]>
apiService.getEnvironment(id): Promise<Environment>
apiService.createEnvironment(data): Promise<Environment> // POST /environments
apiService.updateEnvironment(data): Promise<Environment> // PUT /environments/:id
apiService.deleteEnvironment(id): Promise<void>
Global Variables¶
apiService.getGlobals(): Promise<GlobalVariables>
apiService.updateGlobals(variables): Promise<GlobalVariables>
Execution¶
apiService.executeRequest(data): Promise<SanityResult>
apiService.startLoadTest(data): Promise<StartLoadTestResponse>
Run Management¶
// Paginated, filtered history (newest first). Rows carry a compact `summary`
// (url/method/mode/duration/concurrency/comment), not the full configSnapshot.
apiService.listRuns(params?: RunListParams): Promise<RunListResponse>
// Every page as a flat list (Settings' count + clear).
apiService.listAllRuns(params?): Promise<Run[]>
apiService.getRun(id): Promise<Run> // full configSnapshot
apiService.getRunReport(id): Promise<RunReport>
apiService.stopRun(id): Promise<StopRunResponse>
apiService.deleteRun(id): Promise<void>
Scripting¶
OAuth 2.0¶
apiService.fetchOAuth2Token(data): Promise<OAuth2TokenResponse> // POST /oauth2/token
apiService.getOAuth2TokenStatus(cacheKey): Promise<OAuth2StatusResponse> // GET /oauth2/token?key=
apiService.clearOAuth2Token(cacheKey): Promise<void> // DELETE /oauth2/token?key=
// Interactive Authorization Code flow (engine-hosted loopback + PKCE)
apiService.startOAuth2Authorize(data): Promise<OAuth2AuthorizeStart>
apiService.getOAuth2AuthorizeStatus(attemptId): Promise<OAuth2AuthorizeStatus>
apiService.completeOAuth2Authorize(attemptId, callbackUrl): Promise<OAuth2AuthorizeStatus>
These back the OAuth 2.0 auth editor. TanStack Query wraps the non-interactive
ones in queries/oauth.ts (useOAuth2TokenStatusQuery - polls status ~30s;
useFetchOAuth2TokenMutation, useClearOAuth2TokenMutation). The token
cacheKey is computed client-side by services/oauth/cache-key.ts, byte-identical
to the engine so the app and engine agree on cache slots without a round-trip.
The interactive flow is orchestrated in services/oauth/authorize.ts (opens the
system browser or an embedded Electron window, then polls the engine).
HttpClient.deletetakes an optionalparamsargument so the token-clear call can pass?key=.
SSE Client (services/sse-client.ts)¶
Server-Sent Events client for real-time load test metrics streaming.
Features¶
- Single endpoint: Connects to
/runs/:runId/live. The engine retains a replayable tick topic, so the client connects immediately afterPOST /runswith no attach race - it replays from offset 0 and tails to thecompleteevent (even for sub-second runs). - No custom reconnect loop: The engine sends an explicit
completeevent at normal run end, so aCLOSEDreadyState is treated as terminal. TransientCONNECTINGerrors are left to the browser's built-inEventSourceretry. At run end the app converges on the stored report (GET /runs/:id/report) rather than reconnecting to the stream. - Event Handling:
metricsevents,completeevent,errorhandling - Metrics Parsing:
mapSseMetrics()transforms the engine's camelCase blob to the frontendLoadTestMetricsshape (includes drops, queue-wait, percentiles, bytes, status-code map)
Usage¶
sseClient.connect(
runId: string,
onMessage: (metrics: LoadTestMetrics) => void,
onError: (error: Error) => void,
onClose: () => void
);
sseClient.disconnect();
sseClient.isConnected(): boolean
Event Types¶
metrics: Real-time metrics update (JSON payload)complete: Load test completederror: Connection error (triggers reconnection)open: Connection established
API Endpoints (config/api-endpoints.ts)¶
Centralized endpoint configuration:
export const API_ENDPOINTS = {
BASE_URL: "http://127.0.0.1:9876",
// Health & Config
HEALTH: "/health",
CONFIG: "/config",
// Collections
COLLECTIONS: "/collections",
COLLECTION_BY_ID: (id: string) => `/collections/${id}`,
// Requests
REQUESTS: "/requests",
REQUEST_BY_ID: (id: string) => `/requests/${id}`,
// Execution
EXECUTE_REQUEST: "/execute",
START_LOAD_TEST: "/runs",
// OAuth 2.0
OAUTH2_TOKEN: "/oauth2/token",
OAUTH2_AUTHORIZE_START: "/oauth2/authorize/start",
OAUTH2_AUTHORIZE_COMPLETE: "/oauth2/authorize/complete",
OAUTH2_AUTHORIZE_STATUS: (id: string) => `/oauth2/authorize/${id}`,
// Runs
RUNS: "/runs",
RUN_BY_ID: (id: string) => `/runs/${id}`,
RUN_REPORT: (id: string) => `/runs/${id}/report`,
RUN_STOP: (id: string) => `/runs/${id}/stop`,
// Real-time stats (SSE)
METRICS_LIVE: (runId: string) => `/runs/${runId}/live`,
// Time-series metrics (JSON, paginated) - used to hydrate history
STATS_TIME_SERIES: (runId: string, limit = 5000, offset = 0) =>
`/runs/${runId}/metrics?limit=${limit}&offset=${offset}`,
};
Note: the old
STATS_STREAMSSE constant was removed - live metrics go throughMETRICS_LIVEonly;/statsis now used solely for paginated historical reads.
Request Execution Flow¶
Single Request Execution¶
- User Action: Clicks "Send" in RequestBuilder
- Variable Resolution:
useVariableResolver()resolves{{variables}}in URL, headers, body - Request Transformation: Frontend format → backend format
- API Call:
apiService.executeRequest()→POST /execute - Response Transformation: Backend format → frontend format
- Display: Response shown in ResponseViewer
Auth (bearer/basic/api-key/oauth2) is resolved engine-side from the request's
auth object - the app no longer builds Authorization headers itself. When a
non-interactive OAuth 2.0 token can't be obtained, the response carries an
errorCode of AUTH_REQUIRED (interactive sign-in needed) or AUTH_FAILED, and
the request builder surfaces a toast pointing the user at the Auth tab.
Example Request:
await apiService.executeRequest({
method: "GET",
url: "https://api.example.com/users",
headers: { "Authorization": "Bearer {{token}}" },
preRequestScripts: [
{ origin: "request", id: "req_123", script: "console.log('Pre-request');" }
],
postRequestScripts: [
{
origin: "request",
id: "req_123",
script: "pm.test('Status 200', () => pm.expect(pm.response.code).to.equal(200));"
}
],
followRedirects: true,
maxRedirects: 10,
requestId: "req_123",
environmentId: "env_456"
});
preRequestScripts / postRequestScripts are an ordered list of ScriptParts
({ origin: "collection" | "request", id?, name?, script }), not a single
string: the collection chain's scripts (root→leaf), then the request's own,
built client-side by scriptParts() (request-builder/utils/script-parts.ts
for the renderer, resolve.ts for MCP). The engine joins the parts and runs
the result - see docs/engine/architecture.md → Request composition boundary.
Redirect policy is always sent, never elided. followRedirects and
maxRedirects come from the request's Settings tab and are included on
every execute even when they equal the defaults. The engine defaults
follow_redirects to true, so omitting a false would follow the 3xx the
user asked to inspect - a bug the app shipped with for a long time, when nothing
in the renderer sent these fields at all. The same pair goes out with
startLoadTest(), so a load test exercises the policy the request was
configured with.
Example Response:
{
status: 200,
statusText: "OK",
headers: { "content-type": "application/json" },
body: { users: [...] },
bodyRaw: '{"users":[...]}',
timing: { total: 150, dns: 10, connect: 20, ... },
testResults: [
{ name: "Status 200", passed: true }
],
consoleLogs: ["Pre-request"]
}
Load Test Execution¶
- User Action: Configures and starts load test
- Request Transformation: Frontend
LoadTestConfig→ backend format - API Call:
apiService.startLoadTest()→POST /runs - Response:
{ runId: "run_123", status: "running" } - Dashboard Initialization:
useDashboardStore().startRun(runId) - SSE Connection:
useSSE()connects to/runs/:runId/live - Metrics Streaming: Real-time metrics update dashboard
- Completion: When test completes, fetch final report via
GET /runs/:id/report
Example Load Test Request:
await apiService.startLoadTest({
request: {
method: "POST",
url: "https://api.example.com/data",
headers: { "Content-Type": "application/json" },
body: { mode: "json", content: '{"key": "value"}' }
},
followRedirects: true,
maxRedirects: 10,
mode: "constant_rps",
duration: "30s",
targetRps: 100,
concurrency: 50,
requestId: "req_123",
environmentId: "env_456",
comment: "Stress test"
});
Error Handling¶
HTTP Errors¶
All HTTP errors are transformed to ApiError:
try {
await apiService.executeRequest(...);
} catch (error) {
if (error instanceof ApiError) {
console.error(error.userFriendlyMessage);
console.error(error.statusCode);
console.error(error.errorCode);
}
}
Network Errors¶
Network errors (timeout, connection failed) are caught and displayed:
if (error.isTimeout) {
// Show timeout message
} else if (error.isNetworkError) {
// Show network error message
}
SSE Errors¶
SSE client handles errors automatically:
- Transient errors: Logged, reconnection attempted
- Fatal errors: onError callback called, connection closed
Connection Management¶
Health Checking¶
The app polls /health endpoint to verify engine connectivity:
Health Response:
Engine Startup¶
The Electron main process (electron/sidecar.ts) manages engine lifecycle:
- Spawns engine process on app start
- Monitors health via /health endpoint
- Handles graceful shutdown on app quit
Best Practices¶
- Always use
apiService: Don't callhttpClientdirectly - Handle errors: Always wrap API calls in try/catch
- Use user-friendly messages: Display
error.userFriendlyMessageto users - Transform data: Use service layer for format transformation
- Cache queries: Use TanStack Query for automatic caching
- Disconnect SSE: Always disconnect SSE when component unmounts
Testing¶
Mocking API Calls¶
Use TanStack Query's query client for testing:
import { QueryClient } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } }
});
Mocking Services¶
Mock apiService methods:
Troubleshooting¶
Engine Not Responding¶
- Check if engine is running:
curl http://127.0.0.1:9876/health - Check engine logs in Electron console
- Verify port 9876 is not blocked
SSE Not Connecting¶
- Verify load test is running (
status: "running") - Check browser console for SSE errors
- Verify endpoint:
/runs/:runId/liveor/runs/:runId/metrics
CORS Errors¶
Should not occur (same-origin: localhost), but if they do: - Verify engine CORS settings - Check if request is going to correct origin