diff --git a/AI_CONTEXT.md b/AI_CONTEXT.md index 4a277be..b762d1f 100644 --- a/AI_CONTEXT.md +++ b/AI_CONTEXT.md @@ -259,6 +259,70 @@ Notes: - The gateway uses internal DB context for validation and execution to avoid circular auth checks. - Perform migrations by POSTing DDL statements to `/v1/db/transaction`. +OpenAPI specification: see `openapi/gateway.yaml` for a machine-readable contract covering Storage, Database, and PubSub REST endpoints. + +--- + +## SDK Authoring Guide + +### Base concepts +- Auth: send `X-API-Key: ` or `Authorization: Bearer ` on every request. +- Versioning: all endpoints live under `/v1/`. +- Response envelopes today are pragmatic: + - Success varies by endpoint (e.g., `{status:"ok"}` for mutations, JSON bodies for queries/lists) + - Errors are returned as `{ "error": "message" }` with appropriate HTTP status + +### HTTP Endpoints (recap) +- Storage + - PUT: `POST /v1/storage/put?key=` body: raw bytes + - GET: `GET /v1/storage/get?key=` (returns bytes; some backends may base64-encode) + - EXISTS: `GET /v1/storage/exists?key=` → `{exists:boolean}` + - LIST: `GET /v1/storage/list?prefix=

` → `{keys:[...]}` + - DELETE: `POST /v1/storage/delete` body `{key}` → `{status:"ok"}` +- Database + - Create Table: `POST /v1/db/create-table` `{schema}` → `{status:"ok"}` + - Drop Table: `POST /v1/db/drop-table` `{table}` → `{status:"ok"}` + - Query: `POST /v1/db/query` `{sql, args?}` → `{columns, rows, count}` + - Transaction: `POST /v1/db/transaction` `{statements:[...]}` → `{status:"ok"}` + - Schema: `GET /v1/db/schema` → schema JSON +- PubSub + - WebSocket: `GET /v1/pubsub/ws?topic=` (binary frames; ping keepalive) + - Publish: `POST /v1/pubsub/publish` `{topic, data_base64}` → `{status:"ok"}` + - Topics: `GET /v1/pubsub/topics` → `{topics:[...]}` (already trimmed to namespace) + +### Migrations +- Add a column: `ALTER TABLE users ADD COLUMN age INTEGER` +- Change type / add FK (recreate pattern): + 1. `CREATE TABLE users_new (... updated schema ...)` + 2. `INSERT INTO users_new (...) SELECT ... FROM users` + 3. `DROP TABLE users` + 4. `ALTER TABLE users_new RENAME TO users` +- Wrap the above in a single `POST /v1/db/transaction` call. + +### Suggested SDK surface +- Database + - `createTable(schema: string): Promise` + - `dropTable(name: string): Promise` + - `query(sql: string, args?: any[]): Promise<{ rows: T[] }>` + - `transaction(statements: string[]): Promise` + - `schema(): Promise` +- Storage + - `put(key: string, value: Uint8Array | string): Promise` + - `get(key: string): Promise` + - `exists(key: string): Promise` + - `list(prefix?: string): Promise` + - `delete(key: string): Promise` +- PubSub + - `subscribe(topic: string, handler: (msg: Uint8Array) => void): Promise<() => void>` + - `publish(topic: string, data: Uint8Array | string): Promise` + - `listTopics(): Promise` + +### Reliability guidelines +- Timeouts: 10–30s defaults with per-call overrides +- Retries: exponential backoff on 429/5xx; respect `Retry-After` +- Idempotency: for transactions, consider an `Idempotency-Key` header client-side (gateway may ignore) +- WebSocket: auto-reconnect with jitter; re-subscribe after reconnect + ### Authentication Improvements The gateway authentication system has been significantly enhanced with the following features: diff --git a/Makefile b/Makefile index 51c55e9..ab7f730 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,7 @@ test-e2e: .PHONY: build clean test run-node run-node2 run-node3 run-example deps tidy fmt vet lint clear-ports -VERSION := 0.39.0-beta +VERSION := 0.40.0-beta COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) LDFLAGS := -X 'main.version=$(VERSION)' -X 'main.commit=$(COMMIT)' -X 'main.date=$(DATE)' diff --git a/README.md b/README.md index e4682e7..e732fe0 100644 --- a/README.md +++ b/README.md @@ -478,6 +478,75 @@ POST /v1/pubsub/publish # Publish message to topic GET /v1/pubsub/topics # List active topics ``` +--- + +## SDK Authoring Guide + +### Base concepts +- OpenAPI: a machine-readable spec is available at `openapi/gateway.yaml` for SDK code generation. +- **Auth**: send `X-API-Key: ` or `Authorization: Bearer ` with every request. +- **Versioning**: all endpoints are under `/v1/`. +- **Responses**: mutations return `{status:"ok"}`; queries/lists return JSON; errors return `{ "error": "message" }` with proper HTTP status. + +### Key HTTP endpoints for SDKs +- **Storage** + - PUT: `POST /v1/storage/put?key=` body is raw bytes + - GET: `GET /v1/storage/get?key=` returns bytes (may be base64-encoded by some backends) + - EXISTS: `GET /v1/storage/exists?key=` → `{exists}` + - LIST: `GET /v1/storage/list?prefix=

` → `{keys}` + - DELETE: `POST /v1/storage/delete` `{key}` → `{status:"ok"}` +- **Database** + - Create Table: `POST /v1/db/create-table` `{schema}` → `{status:"ok"}` + - Drop Table: `POST /v1/db/drop-table` `{table}` → `{status:"ok"}` + - Query: `POST /v1/db/query` `{sql, args?}` → `{columns, rows, count}` + - Transaction: `POST /v1/db/transaction` `{statements:[...]}` → `{status:"ok"}` + - Schema: `GET /v1/db/schema` → schema JSON +- **PubSub** + - WS Subscribe: `GET /v1/pubsub/ws?topic=` + - Publish: `POST /v1/pubsub/publish` `{topic, data_base64}` → `{status:"ok"}` + - Topics: `GET /v1/pubsub/topics` → `{topics:[...]}` + +### Migrations +- Add column: `ALTER TABLE users ADD COLUMN age INTEGER` +- Change type / add FK (recreate pattern): create `_new` table, copy data, drop old, rename. +- Always send as one `POST /v1/db/transaction`. + +### Minimal examples + +TypeScript (Node) + +```ts +import { GatewayClient } from "../examples/sdk-typescript/src/client"; + +const client = new GatewayClient(process.env.GATEWAY_BASE_URL!, process.env.GATEWAY_API_KEY!); +await client.createTable("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)"); +const res = await client.query("SELECT name FROM users WHERE id = ?", [1]); +``` + +Python + +```python +import os, requests + +BASE = os.environ['GATEWAY_BASE_URL'] +KEY = os.environ['GATEWAY_API_KEY'] +H = { 'X-API-Key': KEY, 'Content-Type': 'application/json' } + +def query(sql, args=None): + r = requests.post(f'{BASE}/v1/db/query', json={ 'sql': sql, 'args': args or [] }, headers=H, timeout=15) + r.raise_for_status() + return r.json()['rows'] +``` + +Go + +```go +req, _ := http.NewRequest(http.MethodPost, base+"/v1/db/create-table", bytes.NewBufferString(`{"schema":"CREATE TABLE ..."}`)) +req.Header.Set("X-API-Key", apiKey) +req.Header.Set("Content-Type", "application/json") +resp, err := http.DefaultClient.Do(req) +``` + ### Security Features - **Namespace Enforcement:** All operations are automatically prefixed with namespace for isolation diff --git a/examples/sdk-typescript/README.md b/examples/sdk-typescript/README.md new file mode 100644 index 0000000..6e2bc73 --- /dev/null +++ b/examples/sdk-typescript/README.md @@ -0,0 +1,23 @@ +# DeBros Gateway TypeScript SDK (Minimal Example) + +Minimal, dependency-light wrapper around the HTTP Gateway. + +Usage: + +```bash +npm i +export GATEWAY_BASE_URL=http://127.0.0.1:8080 +export GATEWAY_API_KEY=your_api_key +``` + +```ts +import { GatewayClient } from './src/client'; + +const c = new GatewayClient(process.env.GATEWAY_BASE_URL!, process.env.GATEWAY_API_KEY!); +await c.createTable('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)'); +await c.transaction([ + 'INSERT INTO users (id,name) VALUES (1,\'Alice\')' +]); +const res = await c.query('SELECT name FROM users WHERE id = ?', [1]); +console.log(res.rows); +``` diff --git a/examples/sdk-typescript/package.json b/examples/sdk-typescript/package.json new file mode 100644 index 0000000..3aac5f0 --- /dev/null +++ b/examples/sdk-typescript/package.json @@ -0,0 +1,17 @@ +{ + "name": "debros-gateway-sdk", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc -p tsconfig.json" + }, + "dependencies": { + "isomorphic-ws": "^5.0.0" + }, + "devDependencies": { + "typescript": "^5.5.4" + } +} diff --git a/examples/sdk-typescript/src/client.ts b/examples/sdk-typescript/src/client.ts new file mode 100644 index 0000000..154efe7 --- /dev/null +++ b/examples/sdk-typescript/src/client.ts @@ -0,0 +1,112 @@ +import WebSocket from 'isomorphic-ws'; + +export class GatewayClient { + constructor(private baseUrl: string, private apiKey: string, private http = fetch) {} + + private headers(json = true): Record { + const h: Record = { 'X-API-Key': this.apiKey }; + if (json) h['Content-Type'] = 'application/json'; + return h; + } + + // Database + async createTable(schema: string): Promise { + const r = await this.http(`${this.baseUrl}/v1/db/create-table`, { + method: 'POST', headers: this.headers(), body: JSON.stringify({ schema }) + }); + if (!r.ok) throw new Error(`createTable failed: ${r.status}`); + } + + async dropTable(table: string): Promise { + const r = await this.http(`${this.baseUrl}/v1/db/drop-table`, { + method: 'POST', headers: this.headers(), body: JSON.stringify({ table }) + }); + if (!r.ok) throw new Error(`dropTable failed: ${r.status}`); + } + + async query(sql: string, args: any[] = []): Promise<{ rows: T[] }> { + const r = await this.http(`${this.baseUrl}/v1/db/query`, { + method: 'POST', headers: this.headers(), body: JSON.stringify({ sql, args }) + }); + if (!r.ok) throw new Error(`query failed: ${r.status}`); + return r.json(); + } + + async transaction(statements: string[]): Promise { + const r = await this.http(`${this.baseUrl}/v1/db/transaction`, { + method: 'POST', headers: this.headers(), body: JSON.stringify({ statements }) + }); + if (!r.ok) throw new Error(`transaction failed: ${r.status}`); + } + + async schema(): Promise { + const r = await this.http(`${this.baseUrl}/v1/db/schema`, { headers: this.headers(false) }); + if (!r.ok) throw new Error(`schema failed: ${r.status}`); + return r.json(); + } + + // Storage + async put(key: string, value: Uint8Array | string): Promise { + const body = typeof value === 'string' ? new TextEncoder().encode(value) : value; + const r = await this.http(`${this.baseUrl}/v1/storage/put?key=${encodeURIComponent(key)}`, { + method: 'POST', headers: { 'X-API-Key': this.apiKey }, body + }); + if (!r.ok) throw new Error(`put failed: ${r.status}`); + } + + async get(key: string): Promise { + const r = await this.http(`${this.baseUrl}/v1/storage/get?key=${encodeURIComponent(key)}`, { + headers: { 'X-API-Key': this.apiKey } + }); + if (!r.ok) throw new Error(`get failed: ${r.status}`); + const buf = new Uint8Array(await r.arrayBuffer()); + return buf; + } + + async exists(key: string): Promise { + const r = await this.http(`${this.baseUrl}/v1/storage/exists?key=${encodeURIComponent(key)}`, { + headers: this.headers(false) + }); + if (!r.ok) throw new Error(`exists failed: ${r.status}`); + const j = await r.json(); + return !!j.exists; + } + + async list(prefix = ""): Promise { + const r = await this.http(`${this.baseUrl}/v1/storage/list?prefix=${encodeURIComponent(prefix)}`, { + headers: this.headers(false) + }); + if (!r.ok) throw new Error(`list failed: ${r.status}`); + const j = await r.json(); + return j.keys || []; + } + + async delete(key: string): Promise { + const r = await this.http(`${this.baseUrl}/v1/storage/delete`, { + method: 'POST', headers: this.headers(), body: JSON.stringify({ key }) + }); + if (!r.ok) throw new Error(`delete failed: ${r.status}`); + } + + // PubSub (minimal) + subscribe(topic: string, onMessage: (data: Uint8Array) => void): { close: () => void } { + const url = new URL(`${this.baseUrl.replace(/^http/, 'ws')}/v1/pubsub/ws`); + url.searchParams.set('topic', topic); + const ws = new WebSocket(url.toString(), { headers: { 'X-API-Key': this.apiKey } } as any); + ws.binaryType = 'arraybuffer'; + ws.onmessage = (ev: any) => { + const data = ev.data instanceof ArrayBuffer ? new Uint8Array(ev.data) : new TextEncoder().encode(String(ev.data)); + onMessage(data); + }; + return { close: () => ws.close() }; + } + + async publish(topic: string, data: Uint8Array | string): Promise { + const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data; + const b64 = Buffer.from(bytes).toString('base64'); + const r = await this.http(`${this.baseUrl}/v1/pubsub/publish`, { + method: 'POST', headers: this.headers(), body: JSON.stringify({ topic, data_base64: b64 }) + }); + if (!r.ok) throw new Error(`publish failed: ${r.status}`); + } +} diff --git a/examples/sdk-typescript/tsconfig.json b/examples/sdk-typescript/tsconfig.json new file mode 100644 index 0000000..80e7492 --- /dev/null +++ b/examples/sdk-typescript/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ES2020", + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "moduleResolution": "Node" + }, + "include": ["src/**/*"] +} diff --git a/openapi/gateway.yaml b/openapi/gateway.yaml new file mode 100644 index 0000000..1e1cbc2 --- /dev/null +++ b/openapi/gateway.yaml @@ -0,0 +1,249 @@ +openapi: 3.0.3 +info: + title: DeBros Gateway API + version: 1.0.0 + description: REST API over the DeBros Network client for storage, database, and pubsub. +servers: + - url: http://localhost:8080 +security: + - ApiKeyAuth: [] + - BearerAuth: [] +components: + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: X-API-Key + BearerAuth: + type: http + scheme: bearer + schemas: + Error: + type: object + properties: + error: + type: string + QueryRequest: + type: object + required: [sql] + properties: + sql: + type: string + args: + type: array + items: {} + QueryResponse: + type: object + properties: + columns: + type: array + items: + type: string + rows: + type: array + items: + type: array + items: {} + count: + type: integer + format: int64 + TransactionRequest: + type: object + required: [statements] + properties: + statements: + type: array + items: + type: string + CreateTableRequest: + type: object + required: [schema] + properties: + schema: + type: string + DropTableRequest: + type: object + required: [table] + properties: + table: + type: string + TopicsResponse: + type: object + properties: + topics: + type: array + items: + type: string +paths: + /v1/health: + get: + summary: Gateway health + responses: + '200': { description: OK } + /v1/storage/put: + post: + summary: Store a value by key + parameters: + - in: query + name: key + schema: { type: string } + required: true + requestBody: + required: true + content: + application/octet-stream: + schema: + type: string + format: binary + responses: + '201': { description: Created } + '400': { description: Bad Request, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + '401': { description: Unauthorized } + '500': { description: Error, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + /v1/storage/get: + get: + summary: Get a value by key + parameters: + - in: query + name: key + schema: { type: string } + required: true + responses: + '200': + description: OK + content: + application/octet-stream: + schema: + type: string + format: binary + '404': { description: Not Found, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + /v1/storage/exists: + get: + summary: Check key existence + parameters: + - in: query + name: key + schema: { type: string } + required: true + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + exists: + type: boolean + /v1/storage/list: + get: + summary: List keys by prefix + parameters: + - in: query + name: prefix + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + keys: + type: array + items: + type: string + /v1/storage/delete: + post: + summary: Delete a key + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [key] + properties: + key: { type: string } + responses: + '200': { description: OK } + /v1/db/create-table: + post: + summary: Create tables via SQL DDL + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/CreateTableRequest' } + responses: + '201': { description: Created } + '400': { description: Bad Request, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + '500': { description: Error, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + /v1/db/drop-table: + post: + summary: Drop a table + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/DropTableRequest' } + responses: + '200': { description: OK } + /v1/db/query: + post: + summary: Execute a single SQL query + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/QueryRequest' } + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/QueryResponse' } + '400': { description: Bad Request, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + '500': { description: Error, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + /v1/db/transaction: + post: + summary: Execute multiple SQL statements atomically + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/TransactionRequest' } + responses: + '200': { description: OK } + '400': { description: Bad Request, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + '500': { description: Error, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + /v1/db/schema: + get: + summary: Get current database schema + responses: + '200': { description: OK } + /v1/pubsub/publish: + post: + summary: Publish to a topic + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [topic, data_base64] + properties: + topic: { type: string } + data_base64: { type: string } + responses: + '200': { description: OK } + /v1/pubsub/topics: + get: + summary: List topics in caller namespace + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/TopicsResponse' }