Skip to content

Commit 6de0c82

Browse files
feat: add /create-api command + configurable NoSQL sanitization
- Create /create-api command: scaffolds full CRUD API endpoint (types, handler, route, tests) wired into the server with db wrapper - Add automatic NoSQL injection sanitization to db wrapper — strips $-prefixed and dot-containing keys from all query inputs - Make sanitization configurable: DB_SANITIZE_INPUTS=false in .env, sanitize = false in conf, or configureSanitization(false) in code - Wire /create-api into CLAUDE.md Quick Reference and GitHub Pages - Update command count 15 → 16, add command card, update project structure Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 751b251 commit 6de0c82

File tree

6 files changed

+524
-10
lines changed

6 files changed

+524
-10
lines changed

.claude/commands/create-api.md

Lines changed: 393 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,393 @@
1+
---
2+
description: Scaffold a new API endpoint — route, handler, types, tests — wired into the server
3+
argument-hint: <resource-name> [--no-mongo]
4+
allowed-tools: Read, Write, Edit, Grep, Glob, Bash, AskUserQuestion
5+
---
6+
7+
# Create API Endpoint
8+
9+
Scaffold a production-ready API endpoint for: **$ARGUMENTS**
10+
11+
## Step 0 — Auto-Branch (if on main)
12+
13+
Before creating any files, check the current branch:
14+
15+
```bash
16+
git branch --show-current
17+
```
18+
19+
**Default behavior** (`auto_branch = true` in `claude-mastery-project.conf`):
20+
- If on `main` or `master`: automatically create a feature branch and switch to it:
21+
```bash
22+
git checkout -b feat/api-<resource-name>
23+
```
24+
Report: "Created branch `feat/api-<resource>` — main stays untouched."
25+
- If already on a feature branch: proceed
26+
- If not a git repo: skip this check
27+
28+
**To disable:** Set `auto_branch = false` in `claude-mastery-project.conf`. When disabled, warn and ask the user before proceeding on main.
29+
30+
## Step 1 — Gather Context
31+
32+
Before scaffolding, read the current project:
33+
34+
1. **Read `src/server.ts`** (or `src/server.js`, `src/index.ts`) — understand the server setup
35+
2. **Read `src/core/db/index.ts`** — confirm the db wrapper is available
36+
3. **Scan `src/routes/`** — check for existing route patterns to follow
37+
4. **Scan `src/handlers/`** — check for existing handler patterns
38+
5. **Scan `src/types/`** — check for existing type patterns
39+
6. **Read `.env.example`** — check for database config
40+
41+
If `--no-mongo` is in the arguments, skip MongoDB integration. Otherwise, use the db wrapper by default.
42+
43+
## Step 2 — Create Files
44+
45+
Generate these files for the resource:
46+
47+
### File 1: `src/types/<resource>.ts` — Types first (they're the spec)
48+
49+
```typescript
50+
import type { Document, ObjectId } from 'mongodb';
51+
52+
/** Database document shape */
53+
export interface <Resource>Doc extends Document {
54+
_id: ObjectId;
55+
// Add fields based on the resource
56+
createdAt: Date;
57+
updatedAt: Date;
58+
}
59+
60+
/** API request body for creating a resource */
61+
export interface Create<Resource>Body {
62+
// Fields the client sends (NO _id, NO timestamps)
63+
}
64+
65+
/** API request body for updating a resource */
66+
export interface Update<Resource>Body {
67+
// Partial fields the client can update
68+
}
69+
70+
/** API response shape (what the client receives) */
71+
export interface <Resource>Response {
72+
id: string;
73+
// Mapped from the doc — NEVER expose _id directly
74+
createdAt: string;
75+
updatedAt: string;
76+
}
77+
```
78+
79+
### File 2: `src/handlers/<resource>.ts` — Business logic
80+
81+
```typescript
82+
import {
83+
queryOne,
84+
queryMany,
85+
insertOne,
86+
updateOne,
87+
deleteOne,
88+
count,
89+
registerIndex,
90+
} from '../core/db/index.js';
91+
import type { <Resource>Doc, Create<Resource>Body, Update<Resource>Body, <Resource>Response } from '../types/<resource>.js';
92+
import { ObjectId } from 'mongodb';
93+
94+
// ---------------------------------------------------------------------------
95+
// Indexes — registered at import time, created at startup via ensureIndexes()
96+
// ---------------------------------------------------------------------------
97+
98+
registerIndex({ collection: '<resources>', fields: { createdAt: -1 } });
99+
// Add more indexes based on query patterns
100+
101+
// ---------------------------------------------------------------------------
102+
// Helpers
103+
// ---------------------------------------------------------------------------
104+
105+
const COLLECTION = '<resources>';
106+
107+
/** Map a database document to API response (never expose internals) */
108+
function toResponse(doc: <Resource>Doc): <Resource>Response {
109+
return {
110+
id: doc._id.toHexString(),
111+
// Map other fields
112+
createdAt: doc.createdAt.toISOString(),
113+
updatedAt: doc.updatedAt.toISOString(),
114+
};
115+
}
116+
117+
// ---------------------------------------------------------------------------
118+
// CRUD operations
119+
// ---------------------------------------------------------------------------
120+
121+
export async function create<Resource>(body: Create<Resource>Body): Promise<<Resource>Response> {
122+
const now = new Date();
123+
const doc: Omit<<Resource>Doc, '_id'> = {
124+
...body,
125+
createdAt: now,
126+
updatedAt: now,
127+
};
128+
await insertOne(COLLECTION, doc);
129+
// Re-query to get the _id (insertOne uses bulkWrite, no insertedId)
130+
const created = await queryOne<<Resource>Doc>(COLLECTION, { createdAt: now });
131+
if (!created) throw new Error('Failed to create resource');
132+
return toResponse(created);
133+
}
134+
135+
export async function get<Resource>ById(id: string): Promise<<Resource>Response | null> {
136+
if (!ObjectId.isValid(id)) return null;
137+
const doc = await queryOne<<Resource>Doc>(COLLECTION, { _id: new ObjectId(id) });
138+
return doc ? toResponse(doc) : null;
139+
}
140+
141+
export async function list<Resource>s(options: {
142+
page?: number;
143+
limit?: number;
144+
sort?: Record<string, 1 | -1>;
145+
} = {}): Promise<{ data: <Resource>Response[]; total: number; page: number; limit: number }> {
146+
const page = Math.max(1, options.page ?? 1);
147+
const limit = Math.min(100, Math.max(1, options.limit ?? 20));
148+
const sort = options.sort ?? { createdAt: -1 };
149+
150+
const [docs, total] = await Promise.all([
151+
queryMany<<Resource>Doc>(COLLECTION, [
152+
{ $sort: sort },
153+
{ $skip: (page - 1) * limit },
154+
{ $limit: limit },
155+
]),
156+
count(COLLECTION),
157+
]);
158+
159+
return { data: docs.map(toResponse), total, page, limit };
160+
}
161+
162+
export async function update<Resource>(
163+
id: string,
164+
body: Update<Resource>Body
165+
): Promise<<Resource>Response | null> {
166+
if (!ObjectId.isValid(id)) return null;
167+
const filter = { _id: new ObjectId(id) };
168+
await updateOne<<Resource>Doc>(COLLECTION, filter, {
169+
$set: { ...body, updatedAt: new Date() },
170+
});
171+
const updated = await queryOne<<Resource>Doc>(COLLECTION, filter);
172+
return updated ? toResponse(updated) : null;
173+
}
174+
175+
export async function delete<Resource>(id: string): Promise<boolean> {
176+
if (!ObjectId.isValid(id)) return false;
177+
await deleteOne<<Resource>Doc>(COLLECTION, { _id: new ObjectId(id) });
178+
return true;
179+
}
180+
```
181+
182+
### File 3: `src/routes/v1/<resource>.ts` — Routes (thin, no logic)
183+
184+
```typescript
185+
import { Router } from 'express';
186+
import type { Request, Response } from 'express';
187+
import {
188+
create<Resource>,
189+
get<Resource>ById,
190+
list<Resource>s,
191+
update<Resource>,
192+
delete<Resource>,
193+
} from '../../handlers/<resource>.js';
194+
195+
const router = Router();
196+
197+
// POST /api/v1/<resources>
198+
router.post('/', async (req: Request, res: Response) => {
199+
try {
200+
const result = await create<Resource>(req.body);
201+
res.status(201).json(result);
202+
} catch (err) {
203+
console.error('Create <resource> failed:', err);
204+
res.status(500).json({ error: 'Internal server error' });
205+
}
206+
});
207+
208+
// GET /api/v1/<resources>
209+
router.get('/', async (req: Request, res: Response) => {
210+
try {
211+
const page = parseInt(req.query.page as string) || 1;
212+
const limit = parseInt(req.query.limit as string) || 20;
213+
const result = await list<Resource>s({ page, limit });
214+
res.json(result);
215+
} catch (err) {
216+
console.error('List <resources> failed:', err);
217+
res.status(500).json({ error: 'Internal server error' });
218+
}
219+
});
220+
221+
// GET /api/v1/<resources>/:id
222+
router.get('/:id', async (req: Request, res: Response) => {
223+
try {
224+
const result = await get<Resource>ById(req.params.id);
225+
if (!result) {
226+
res.status(404).json({ error: '<Resource> not found' });
227+
return;
228+
}
229+
res.json(result);
230+
} catch (err) {
231+
console.error('Get <resource> failed:', err);
232+
res.status(500).json({ error: 'Internal server error' });
233+
}
234+
});
235+
236+
// PATCH /api/v1/<resources>/:id
237+
router.patch('/:id', async (req: Request, res: Response) => {
238+
try {
239+
const result = await update<Resource>(req.params.id, req.body);
240+
if (!result) {
241+
res.status(404).json({ error: '<Resource> not found' });
242+
return;
243+
}
244+
res.json(result);
245+
} catch (err) {
246+
console.error('Update <resource> failed:', err);
247+
res.status(500).json({ error: 'Internal server error' });
248+
}
249+
});
250+
251+
// DELETE /api/v1/<resources>/:id
252+
router.delete('/:id', async (req: Request, res: Response) => {
253+
try {
254+
const found = await delete<Resource>(req.params.id);
255+
if (!found) {
256+
res.status(404).json({ error: '<Resource> not found' });
257+
return;
258+
}
259+
res.status(204).send();
260+
} catch (err) {
261+
console.error('Delete <resource> failed:', err);
262+
res.status(500).json({ error: 'Internal server error' });
263+
}
264+
});
265+
266+
export default router;
267+
```
268+
269+
### File 4: Wire into server — Add to `src/server.ts`
270+
271+
Add the import and route registration to the existing server:
272+
273+
```typescript
274+
// Add this import
275+
import <resource>Routes from './routes/v1/<resource>.js';
276+
277+
// Add this route registration (with /api/v1/ prefix)
278+
app.use('/api/v1/<resources>', <resource>Routes);
279+
```
280+
281+
### File 5: `tests/unit/<resource>.test.ts` — Unit tests
282+
283+
```typescript
284+
import { describe, it, expect, vi, beforeEach } from 'vitest';
285+
// Test the handler functions directly, mock the db layer
286+
287+
describe('<Resource> Handlers', () => {
288+
describe('create<Resource>', () => {
289+
it('should create a new <resource> and return response', async () => {
290+
// Arrange — mock db calls
291+
// Act — call create<Resource>
292+
// Assert — verify response shape, timestamps set
293+
});
294+
});
295+
296+
describe('get<Resource>ById', () => {
297+
it('should return <resource> when found', async () => {
298+
// Test happy path
299+
});
300+
301+
it('should return null for invalid ObjectId', async () => {
302+
// Test invalid id returns null
303+
});
304+
305+
it('should return null when not found', async () => {
306+
// Test missing doc returns null
307+
});
308+
});
309+
310+
describe('list<Resource>s', () => {
311+
it('should return paginated results', async () => {
312+
// Test pagination defaults
313+
});
314+
315+
it('should cap limit at 100', async () => {
316+
// Test max limit enforcement
317+
});
318+
});
319+
320+
describe('update<Resource>', () => {
321+
it('should update and return updated doc', async () => {
322+
// Test updatedAt is refreshed
323+
});
324+
});
325+
326+
describe('delete<Resource>', () => {
327+
it('should return true when deleted', async () => {
328+
// Test happy path
329+
});
330+
331+
it('should return false for invalid ObjectId', async () => {
332+
// Test invalid id
333+
});
334+
});
335+
});
336+
```
337+
338+
## Step 3 — Best Practices Enforced
339+
340+
Every generated endpoint MUST follow these rules:
341+
342+
### Security
343+
- All user input passes through the db wrapper's automatic NoSQL sanitization
344+
- NEVER trust `req.body` types at runtime — validate or use Zod schemas
345+
- NEVER expose `_id` directly — always map to `id: string`
346+
- NEVER expose internal error details to the client
347+
- ALWAYS return generic "Internal server error" for unexpected errors
348+
349+
### Performance
350+
- Pagination on ALL list endpoints (default 20, max 100)
351+
- Indexes registered for all query patterns (`registerIndex()`)
352+
- Uses shared connection pool via db wrapper (NEVER creates new connections)
353+
- `$limit` enforced before `$lookup` in any join queries
354+
355+
### Architecture
356+
- Routes are THIN — no business logic, just parse and delegate
357+
- Handlers contain ALL business logic
358+
- Types defined FIRST — they're the contract
359+
- One handler file per resource domain
360+
- One route file per resource
361+
362+
### Node.js Best Practices
363+
- Async error handling with try/catch on every route
364+
- Proper HTTP status codes (201 created, 204 no content, 404 not found)
365+
- JSON responses on all endpoints (including errors)
366+
- No callback-style code — async/await only
367+
- Uses the project's shared MongoDB pool (never creates its own)
368+
369+
## Step 4 — Verification Checklist
370+
371+
After generating, verify:
372+
373+
- [ ] Types file created at `src/types/<resource>.ts`
374+
- [ ] Handler file created at `src/handlers/<resource>.ts`
375+
- [ ] Route file created at `src/routes/v1/<resource>.ts`
376+
- [ ] Routes wired into `src/server.ts` with `/api/v1/` prefix
377+
- [ ] Test file created at `tests/unit/<resource>.test.ts`
378+
- [ ] All CRUD operations: create, read (single + list), update, delete
379+
- [ ] Pagination on list endpoint (default 20, max 100)
380+
- [ ] Indexes registered with `registerIndex()`
381+
- [ ] No `any` types
382+
- [ ] No file exceeds 300 lines
383+
- [ ] _id never exposed — mapped to id string
384+
- [ ] All errors caught and logged
385+
- [ ] Uses db wrapper imports (NEVER raw MongoClient)
386+
387+
## RuleCatch Report
388+
389+
After all files are created and verified, check RuleCatch:
390+
391+
- If the RuleCatch MCP server is available: query for violations in the new API files
392+
- Report any violations found (type issues, missing assertions, security, etc.)
393+
- If no MCP: remind the user — "Check your RuleCatch dashboard for any violations in the new endpoint files"

0 commit comments

Comments
 (0)