Add Node API gateway standard endpoints

This commit is contained in:
Alexa Amundson
2025-11-20 20:24:09 -06:00
parent 401bf30ae7
commit a95af16e63
22 changed files with 5912 additions and 79 deletions

7
.env.example Normal file
View File

@@ -0,0 +1,7 @@
OS_ROOT=https://blackroad.systems
SERVICE_BASE_URL=https://api.blackroad.systems
CORE_BASE_URL=https://core.blackroad.systems
OPERATOR_BASE_URL=https://operator.blackroad.systems
LOG_LEVEL=info
NODE_ENV=development
PORT=8080

6
.gitignore vendored
View File

@@ -205,3 +205,9 @@ cython_debug/
marimo/_static/ marimo/_static/
marimo/_lsp/ marimo/_lsp/
__marimo__/ __marimo__/
# Node.js
node_modules/
dist/
coverage/
npm-debug.log*

17
Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
# Build stage
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# Runtime stage
FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/package*.json ./
RUN npm install --omit=dev
COPY --from=builder /app/dist ./dist
ENV PORT=8080
EXPOSE 8080
CMD ["npm", "start"]

View File

@@ -1,86 +1,61 @@
# BlackRoad OS Public API Gateway # BlackRoad OS Public API
FastAPI-powered public gateway that fronts Core and Agents services with versioned routing, thin proxying, and API key enforcement. Public API gateway for the BlackRoad Operating System. This service exposes common health/info endpoints and versioned API routes that coordinate with core BlackRoad services.
## What it does ## Endpoints
- Exposes `/health` and `/version` for liveness and build metadata. - `GET /health` Liveness check
- Adds `/v1` routes with API key authentication and consistent error wrapping. - `GET /info` Service metadata
- Proxies requests to upstreams: - `GET /version` Version info
- `/v1/core/*``CORE_API_URL` - `GET /debug/env` Safe subset of environment values
- `/v1/agents/*``AGENTS_API_URL` - `GET /v1/ping` Example API endpoint
## Configuration
Environment variables are loaded through `app.config.Settings`.
| Variable | Required | Description |
| --- | --- | --- |
| `NODE_ENV` | No (default `development`) | Deployment environment label. |
| `PUBLIC_API_URL` | Yes (non-dev) | External URL for this gateway. |
| `CORE_API_URL` | Yes (non-dev) | Upstream Core backend base URL. |
| `AGENTS_API_URL` | No | Upstream Agents API base URL. |
| `API_KEYS` | Yes (non-dev) | Comma-separated list of API keys authorized for `/v1` routes. |
| `PUBLIC_API_KEY` | Optional | Single API key value if not using `API_KEYS`. |
| `LOG_LEVEL` | No | Application log level (default `info`). |
| `REQUEST_TIMEOUT_MS` | No | Upstream request timeout in milliseconds (default `10000`). |
| `GIT_COMMIT` / `RAILWAY_GIT_COMMIT_SHA` | No | Commit SHA used for `/version`. |
| `BUILD_TIME` | No | Build timestamp used for `/version`. |
## Running locally ## Running locally
1. Install dependencies: 1. Install dependencies:
```bash ```bash
pip install -r requirements.txt npm install
``` ```
2. Export a sample API key (either `API_KEYS` or `PUBLIC_API_KEY` works): 2. Start the development server:
```bash ```bash
export PUBLIC_API_KEY=local-dev-key npm run dev
export CORE_API_URL=http://localhost:9000 # point to your Core service
export AGENTS_API_URL=http://localhost:9100 # optional
``` ```
3. Start the gateway: The API listens on `http://localhost:8080` by default.
## Build and start
```bash ```bash
uvicorn app.main:app --reload --port 8000 npm run build
npm start
``` ```
## Example requests ## Environment variables
Health and version (no API key required): See `.env.example` for defaults. Key values:
- `OS_ROOT` Base URL for the BlackRoad OS
- `SERVICE_BASE_URL` External URL for this public API
- `CORE_BASE_URL` Core service base URL
- `OPERATOR_BASE_URL` Operator service base URL
- `LOG_LEVEL` Logging verbosity
- `PORT` Port to bind (default `8080`)
## Tests
Run the test suite with:
```bash ```bash
curl http://localhost:8000/health npm test
curl http://localhost:8000/version
curl http://localhost:8000/v1/health
``` ```
Proxying upstreams (API key required): ## Deployment (Railway)
```bash Railway uses `railway.json`:
# Ping Core
curl -H "x-api-key: local-dev-key" http://localhost:8000/v1/core/ping
# Forward any Core path - Build: `npm install && npm run build`
curl -X POST \ - Start: `npm start`
-H "x-api-key: local-dev-key" \ - Healthcheck: `/health` on port `8080`
-H "Content-Type: application/json" \
-d '{"hello": "world"}' \
"http://localhost:8000/v1/core/some/path"
# Forward any Agents path
curl -H "x-api-key: local-dev-key" "http://localhost:8000/v1/agents/demo"
```
## Deployment
- **Railway project**: `blackroad-core`
- **Service name**: `public-api`
- **Build**: `pip install -r requirements.txt`
- **Start**: `uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}`
GitHub Actions workflow `.github/workflows/api-deploy.yaml` deploys to Railway on `dev`, `staging`, and `main` branches and runs health checks against `/health` and `/v1/health` after each deploy.

10
jest.config.js Normal file
View File

@@ -0,0 +1,10 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
roots: ["<rootDir>/tests"],
moduleFileExtensions: ["ts", "js", "json"],
moduleNameMapper: {
"^@/(.*)": "<rootDir>/src/$1",
},
};

5517
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "blackroad-os-public-api",
"version": "0.1.0",
"description": "BlackRoad OS Public API gateway service",
"main": "dist/index.js",
"scripts": {
"dev": "NODE_ENV=development ts-node-dev src/index.ts",
"build": "tsc",
"start": "NODE_ENV=production node dist/index.js",
"test": "NODE_ENV=test jest"
},
"dependencies": {
"cors": "^2.8.5",
"express": "^4.19.2"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.12",
"@types/node": "^20.12.12",
"@types/supertest": "^2.0.16",
"jest": "^29.7.0",
"supertest": "^6.3.4",
"ts-jest": "^29.1.1",
"ts-node-dev": "^2.0.0",
"typescript": "^5.4.5"
}
}

View File

@@ -1,22 +1,10 @@
{ {
"$schema": "https://railway.app/railway.schema.json", "build": "npm install && npm run build",
"name": "public-api", "start": "npm start",
"project": "blackroad-core",
"service": { "service": {
"name": "public-api", "port": 8080,
"envVar": "PORT", "healthcheck": {
"startCommand": "uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}", "path": "/health"
"buildCommand": "pip install -r requirements.txt", }
"healthcheckPath": "/health" }
},
"variables": [
"NODE_ENV",
"PUBLIC_API_URL",
"CORE_API_URL",
"AGENTS_API_URL",
"API_KEYS",
"PUBLIC_API_KEY",
"LOG_LEVEL",
"REQUEST_TIMEOUT_MS"
]
} }

View File

@@ -0,0 +1,19 @@
export const SERVICE_ID = "api";
export const SERVICE_NAME = "BlackRoad OS Public API";
export const SERVICE_BASE_URL =
process.env.SERVICE_BASE_URL || "https://api.blackroad.systems";
export const OS_ROOT = process.env.OS_ROOT || "https://blackroad.systems";
export const CORE_BASE_URL =
process.env.CORE_BASE_URL || "https://core.blackroad.systems";
export const OPERATOR_BASE_URL =
process.env.OPERATOR_BASE_URL || "https://operator.blackroad.systems";
export const serviceConfig = {
SERVICE_ID,
SERVICE_NAME,
SERVICE_BASE_URL,
OS_ROOT,
CORE_BASE_URL,
OPERATOR_BASE_URL,
};

39
src/index.ts Normal file
View File

@@ -0,0 +1,39 @@
import express from "express";
import cors from "cors";
import healthRouter from "./routes/health";
import infoRouter from "./routes/info";
import versionRouter from "./routes/version";
import debugEnvRouter from "./routes/debugEnv";
import v1PingRouter from "./routes/v1/ping";
import { loggingMiddleware } from "./middleware/logging";
import { errorHandler } from "./middleware/errorHandler";
import { SERVICE_ID } from "./config/serviceConfig";
const app = express();
app.use(cors());
app.use(express.json());
app.use(loggingMiddleware);
app.use(healthRouter);
app.use(infoRouter);
app.use(versionRouter);
app.use(debugEnvRouter);
app.use("/v1", v1PingRouter);
app.use(errorHandler);
const PORT = process.env.PORT ? Number(process.env.PORT) : 8080;
if (process.env.NODE_ENV !== "test") {
app.listen(PORT, () => {
console.log(
JSON.stringify({
ts: new Date().toISOString(),
message: `Service ${SERVICE_ID} listening on port ${PORT}`,
})
);
});
}
export default app;

View File

@@ -0,0 +1,22 @@
import { Request, Response, NextFunction } from "express";
import { SERVICE_ID } from "../config/serviceConfig";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const errorHandler = (
err: Error,
req: Request,
res: Response,
next: NextFunction
): void => {
const status = res.statusCode >= 400 ? res.statusCode : 500;
if (process.env.NODE_ENV !== "test") {
console.error(err);
}
res.status(status).json({
ok: false,
error: err.message || "Internal Server Error",
service: SERVICE_ID,
});
};

28
src/middleware/logging.ts Normal file
View File

@@ -0,0 +1,28 @@
import { Request, Response, NextFunction } from "express";
import { SERVICE_ID } from "../config/serviceConfig";
export const loggingMiddleware = (
req: Request,
res: Response,
next: NextFunction
): void => {
const start = process.hrtime.bigint();
res.on("finish", () => {
const end = process.hrtime.bigint();
const durationMs = Number(end - start) / 1_000_000;
const logEntry = {
ts: new Date().toISOString(),
method: req.method,
path: req.originalUrl || req.url,
status: res.statusCode,
duration_ms: Number(durationMs.toFixed(3)),
service_id: SERVICE_ID,
};
console.log(JSON.stringify(logEntry));
});
next();
};

25
src/routes/debugEnv.ts Normal file
View File

@@ -0,0 +1,25 @@
import { Router, Request, Response } from "express";
import {
SERVICE_ID,
OS_ROOT,
CORE_BASE_URL,
OPERATOR_BASE_URL,
} from "../config/serviceConfig";
const router = Router();
router.get("/debug/env", (req: Request, res: Response) => {
res.json({
ok: true,
service: SERVICE_ID,
env: {
NODE_ENV: process.env.NODE_ENV,
OS_ROOT,
LOG_LEVEL: process.env.LOG_LEVEL,
CORE_BASE_URL,
OPERATOR_BASE_URL,
},
});
});
export default router;

14
src/routes/health.ts Normal file
View File

@@ -0,0 +1,14 @@
import { Router, Request, Response } from "express";
import { SERVICE_ID } from "../config/serviceConfig";
const router = Router();
router.get("/health", (req: Request, res: Response) => {
res.json({
ok: true,
service: SERVICE_ID,
ts: new Date().toISOString(),
});
});
export default router;

17
src/routes/info.ts Normal file
View File

@@ -0,0 +1,17 @@
import { Router, Request, Response } from "express";
import packageInfo from "../../package.json";
import { SERVICE_ID, SERVICE_NAME } from "../config/serviceConfig";
const router = Router();
router.get("/info", (req: Request, res: Response) => {
res.json({
name: SERVICE_NAME,
id: SERVICE_ID,
version: packageInfo.version,
time: new Date().toISOString(),
env: process.env.NODE_ENV || "development",
});
});
export default router;

15
src/routes/v1/ping.ts Normal file
View File

@@ -0,0 +1,15 @@
import { Router, Request, Response } from "express";
import { SERVICE_ID, SERVICE_NAME } from "../../config/serviceConfig";
const router = Router();
router.get("/ping", (req: Request, res: Response) => {
res.json({
ok: true,
api: SERVICE_NAME,
service: SERVICE_ID,
ts: new Date().toISOString(),
});
});
export default router;

14
src/routes/version.ts Normal file
View File

@@ -0,0 +1,14 @@
import { Router, Request, Response } from "express";
import packageInfo from "../../package.json";
import { SERVICE_ID } from "../config/serviceConfig";
const router = Router();
router.get("/version", (req: Request, res: Response) => {
res.json({
version: packageInfo.version,
service: SERVICE_ID,
});
});
export default router;

37
src/services.ts Normal file
View File

@@ -0,0 +1,37 @@
export type ServiceDescriptor = {
id: string;
name: string;
baseUrl: string;
healthEndpoint: string;
infoEndpoint: string;
};
export const apiService: ServiceDescriptor = {
id: "api",
name: "BlackRoad OS Public API",
baseUrl: process.env.SERVICE_BASE_URL || "https://api.blackroad.systems",
healthEndpoint: "/health",
infoEndpoint: "/info",
};
export const coreService: ServiceDescriptor = {
id: "core",
name: "BlackRoad OS Core",
baseUrl: process.env.CORE_BASE_URL || "https://core.blackroad.systems",
healthEndpoint: "/health",
infoEndpoint: "/info",
};
export const operatorService: ServiceDescriptor = {
id: "operator",
name: "BlackRoad OS Operator",
baseUrl: process.env.OPERATOR_BASE_URL || "https://operator.blackroad.systems",
healthEndpoint: "/health",
infoEndpoint: "/info",
};
export const servicesRegistry = {
api: apiService,
core: coreService,
operator: operatorService,
};

12
tests/health.test.ts Normal file
View File

@@ -0,0 +1,12 @@
import request from "supertest";
import app from "../src/index";
describe("GET /health", () => {
it("returns ok status and service id", async () => {
const response = await request(app).get("/health");
expect(response.status).toBe(200);
expect(response.body).toHaveProperty("ok", true);
expect(response.body).toHaveProperty("service", "api");
});
});

16
tests/info.test.ts Normal file
View File

@@ -0,0 +1,16 @@
import request from "supertest";
import app from "../src/index";
import packageInfo from "../package.json";
describe("GET /info", () => {
it("returns service metadata", async () => {
const response = await request(app).get("/info");
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
id: "api",
name: "BlackRoad OS Public API",
version: packageInfo.version,
});
});
});

12
tests/ping.test.ts Normal file
View File

@@ -0,0 +1,12 @@
import request from "supertest";
import app from "../src/index";
describe("GET /v1/ping", () => {
it("returns ok response", async () => {
const response = await request(app).get("/v1/ping");
expect(response.status).toBe(200);
expect(response.body).toHaveProperty("ok", true);
expect(response.body).toHaveProperty("service", "api");
});
});

15
tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}