Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions PR_DESCRIPTION_BULK_ENDPOINTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Bulk Endpoint Registration

Adds `POST /api/apis/:id/endpoints/bulk` to register multiple endpoints atomically.

## Changes

### New Route
- `POST /api/apis/:id/endpoints/bulk` — registers multiple endpoints for an existing API in a single transaction
- Requires authentication (bearer token or `x-user-id` header)
- Validates the authenticated developer owns the API
- Validates endpoints with Zod (`bulkEndpointsSchema`)
- Returns `201` with per-row endpoint results (id, api_id, path, method, price_per_call_usdc, description)
- Caps batch size at 50 endpoints (configurable via `BULK_ENDPOINT_LIMIT`)

### Repository Layer
- Added `bulkCreateEndpoints(apiId, endpoints)` to `ApiRepository` interface
- Implemented in `DrizzleApiRepository` using `db.transaction()` — full rollback on failure
- Implemented in `InMemoryApiRepository` for testing
- Added `defaultApiRepository.bulkCreateEndpoints` with cache invalidation

### Validator
- Added `bulkEndpointsSchema` to `src/validators/apiRegistration.ts`
- Reuses the existing `apiEndpointRegistrationSchema` for individual endpoint validation
- Enforces `min 1, max 50` endpoints

### Configuration
- Added `BULK_ENDPOINT_LIMIT` env var (default: 50) in `src/config/env.ts`

### OpenAPI
- Added `/api/apis/{id}/endpoints/bulk` path with request/response schemas

### Tests
- 8 new tests in `src/routes/apis.test.ts` covering:
- Unauthorized access
- Invalid API ID
- API not found / not owned
- Empty endpoints array
- Invalid endpoint data
- Successful bulk creation with per-row results
- Exceeding the 50-endpoint limit
- Persistence verification via GET /:id

## Test Output

```text
PASS src/routes/apis.test.ts
POST /api/apis/:id/endpoints/bulk
✓ returns 401 without authentication (154 ms)
✓ returns 400 when id is not a positive integer (37 ms)
✓ returns 404 when the API does not belong to the developer (7 ms)
✓ returns 400 with empty endpoints array (7 ms)
✓ returns 400 when endpoint data is invalid (12 ms)
✓ creates endpoints and returns per-row results (5 ms)
✓ rejects more than 50 endpoints (7 ms)
✓ persists endpoints that can be retrieved via GET /:id (8 ms)
```

Closes #400
167 changes: 167 additions & 0 deletions docs/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,90 @@
}
}
}
},
"/api/apis/{id}/endpoints/bulk": {
"post": {
"summary": "Bulk register endpoints for an API",
"description": "Registers multiple endpoints atomically for the specified API. All endpoints are inserted in a single transaction; if any endpoint is invalid the entire batch is rolled back. Authenticated developer must own the API.",
"security": [
{
"bearerAuth": []
}
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer"
},
"description": "Numerical ID of the API"
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BulkEndpointRegistrationRequest"
}
}
}
},
"responses": {
"201": {
"description": "Endpoints registered successfully — per-row details returned",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BulkEndpointRegistrationResponse"
}
}
}
},
"400": {
"description": "Validation error — invalid endpoint data, empty array, or too many endpoints",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"404": {
"description": "API not found or does not belong to the developer",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {
"description": "Internal server error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
}
},
"components": {
Expand Down Expand Up @@ -853,6 +937,89 @@
}
}
},
"BulkEndpointRegistrationRequest": {
"type": "object",
"required": [
"endpoints"
],
"properties": {
"endpoints": {
"type": "array",
"minItems": 1,
"maxItems": 50,
"items": {
"type": "object",
"required": [
"path",
"method",
"price_per_call_usdc"
],
"properties": {
"path": {
"type": "string",
"description": "URL path starting with /"
},
"method": {
"type": "string",
"enum": ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]
},
"price_per_call_usdc": {
"type": "string",
"description": "Non-negative decimal string (e.g. \"0.01\")"
},
"description": {
"type": "string",
"description": "Optional human-readable description"
}
}
}
}
}
},
"BulkEndpointRegistrationResponse": {
"type": "object",
"required": [
"endpoints"
],
"properties": {
"endpoints": {
"type": "array",
"items": {
"type": "object",
"required": [
"id",
"api_id",
"path",
"method",
"price_per_call_usdc"
],
"properties": {
"id": {
"type": "integer",
"description": "Auto-generated endpoint ID"
},
"api_id": {
"type": "integer",
"description": "Owning API ID"
},
"path": {
"type": "string"
},
"method": {
"type": "string"
},
"price_per_call_usdc": {
"type": "string"
},
"description": {
"type": "string",
"nullable": true
}
}
}
}
}
},
"ApiDetailsResponse": {
"type": "object",
"required": [
Expand Down
15 changes: 15 additions & 0 deletions src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,21 @@ class FakeApiRepository implements ApiRepository {
async getEndpoints() {
return [];
}

async delete() {
return true;
}

async bulkCreateEndpoints(_apiId: number, endpoints: import('./repositories/apiRepository.js').CreateEndpointInput[]) {
return endpoints.map((e, i) => ({
id: i + 1,
api_id: _apiId,
path: e.path,
method: e.method,
price_per_call_usdc: e.price_per_call_usdc,
description: e.description ?? null,
}));
}
}

const createDeveloperRepository = (profile?: Developer): DeveloperRepository => ({
Expand Down
1 change: 1 addition & 0 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,4 +204,5 @@ export const config = {
listingsCache: {
warmupTimeoutMs: env.LISTINGS_CACHE_WARMUP_TIMEOUT_MS,
},
bulkEndpointLimit: env.BULK_ENDPOINT_LIMIT,
} as const;
40 changes: 37 additions & 3 deletions src/repositories/apiRepository.drizzle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import type { Api, ApiEndpoint, NewApi, NewApiEndpoint } from "../db/schema.js";
import type {
ApiCreateInput,
ApiWithEndpoints,
BulkCreateEndpointResult,
CreateApiInput,
CreateEndpointInput,
ApiDetails,
ApiEndpointInfo,
ApiListFilters,
Expand Down Expand Up @@ -111,11 +113,11 @@ export class DrizzleApiRepository implements ApiRepository {
}

async delete(id: number): Promise<boolean> {
const deleted = await db.delete(schema.apis).where(eq(schema.apis.id, id));
const result = await db.delete(schema.apis).where(eq(schema.apis.id, id));

// Drizzle's delete() returns the number of rows deleted.
// Drizzle's delete() with better-sqlite3 returns a RunResult { changes, lastInsertRowid }.
// The database FK with ON DELETE CASCADE will automatically clean up endpoints.
return deleted > 0;
return result.changes > 0;
}

async listByDeveloper(
Expand Down Expand Up @@ -237,4 +239,36 @@ export class DrizzleApiRepository implements ApiRepository {
description: r.description,
}));
}

async bulkCreateEndpoints(
apiId: number,
endpoints: CreateEndpointInput[],
): Promise<BulkCreateEndpointResult[]> {
return db.transaction(async (tx) => {
const rows = await tx
.insert(schema.apiEndpoints)
.values(
endpoints.map(
(e) =>
({
api_id: apiId,
path: e.path,
method: e.method,
price_per_call_usdc: e.price_per_call_usdc,
description: e.description ?? null,
}) as NewApiEndpoint,
),
)
.returning();

return rows.map((r) => ({
id: r.id,
api_id: r.api_id,
path: r.path,
method: r.method,
price_per_call_usdc: r.price_per_call_usdc,
description: r.description,
}));
});
}
}
Loading
Loading