@jaypie/mcp 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -3
- package/prompts/Branch_Management.md +34 -0
- package/prompts/Development_Process.md +67 -0
- package/prompts/Jaypie_Agent_Rules.md +110 -0
- package/prompts/Jaypie_Auth0_Express_Mongoose.md +736 -0
- package/prompts/Jaypie_Browser_and_Frontend_Web_Packages.md +18 -0
- package/prompts/Jaypie_CDK_Constructs_and_Patterns.md +156 -0
- package/prompts/Jaypie_CICD_with_GitHub_Actions.md +151 -0
- package/prompts/Jaypie_Commander_CLI_Package.md +166 -0
- package/prompts/Jaypie_Core_Errors_and_Logging.md +39 -0
- package/prompts/Jaypie_Eslint_NPM_Package.md +78 -0
- package/prompts/Jaypie_Ideal_Project_Structure.md +78 -0
- package/prompts/Jaypie_Init_Express_on_Lambda.md +87 -0
- package/prompts/Jaypie_Init_Jaypie_CDK_Package.md +35 -0
- package/prompts/Jaypie_Init_Lambda_Package.md +245 -0
- package/prompts/Jaypie_Init_Monorepo_Project.md +44 -0
- package/prompts/Jaypie_Init_Project_Subpackage.md +70 -0
- package/prompts/Jaypie_Legacy_Patterns.md +11 -0
- package/prompts/Jaypie_Llm_Calls.md +113 -0
- package/prompts/Jaypie_Llm_Tools.md +124 -0
- package/prompts/Jaypie_Mocks_and_Testkit.md +137 -0
- package/prompts/Jaypie_Mongoose_Models_Package.md +231 -0
- package/prompts/Jaypie_Mongoose_with_Express_CRUD.md +1000 -0
- package/prompts/Jaypie_Scrub.md +177 -0
- package/prompts/Write_Efficient_Prompt_Guides.md +48 -0
- package/prompts/Write_and_Maintain_Engaging_Readme.md +67 -0
- package/prompts/templates/cdk-subpackage/bin/cdk.ts +11 -0
- package/prompts/templates/cdk-subpackage/cdk.json +19 -0
- package/prompts/templates/cdk-subpackage/lib/cdk-app.ts +41 -0
- package/prompts/templates/cdk-subpackage/lib/cdk-infrastructure.ts +15 -0
- package/prompts/templates/express-subpackage/index.ts +8 -0
- package/prompts/templates/express-subpackage/src/app.ts +18 -0
- package/prompts/templates/express-subpackage/src/handler.config.ts +44 -0
- package/prompts/templates/express-subpackage/src/routes/resource/__tests__/resourceGet.route.spec.ts +29 -0
- package/prompts/templates/express-subpackage/src/routes/resource/resourceGet.route.ts +22 -0
- package/prompts/templates/express-subpackage/src/routes/resource.router.ts +11 -0
- package/prompts/templates/express-subpackage/src/types/express.ts +9 -0
- package/prompts/templates/project-monorepo/.vscode/settings.json +72 -0
- package/prompts/templates/project-monorepo/eslint.config.mjs +1 -0
- package/prompts/templates/project-monorepo/package.json +20 -0
- package/prompts/templates/project-monorepo/tsconfig.base.json +18 -0
- package/prompts/templates/project-monorepo/tsconfig.json +6 -0
- package/prompts/templates/project-monorepo/vitest.workspace.js +3 -0
- package/prompts/templates/project-subpackage/package.json +16 -0
- package/prompts/templates/project-subpackage/tsconfig.json +11 -0
- package/prompts/templates/project-subpackage/vite.config.ts +21 -0
- package/prompts/templates/project-subpackage/vitest.config.ts +7 -0
- package/prompts/templates/project-subpackage/vitest.setup.ts +6 -0
|
@@ -0,0 +1,1000 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: MongoDB/Mongoose integration with Jaypie Express patterns
|
|
3
|
+
globs: packages/express/**
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Jaypie Mongoose with Express CRUD
|
|
7
|
+
|
|
8
|
+
MongoDB/Mongoose integration patterns for Jaypie Express applications with full CRUD operations, authentication, and relationship management using TypeScript.
|
|
9
|
+
|
|
10
|
+
## Pattern
|
|
11
|
+
|
|
12
|
+
- TypeScript with ES modules (`"type": "module"`)
|
|
13
|
+
- Model package with MongoDB/Mongoose connections
|
|
14
|
+
- Handler configuration with connection lifecycle
|
|
15
|
+
- Authentication/authorization with user context
|
|
16
|
+
- JSON:API formatting for consistent responses
|
|
17
|
+
- Relationship management with foreign keys
|
|
18
|
+
- Type-safe interfaces for all data models
|
|
19
|
+
|
|
20
|
+
## Dependencies
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@yourorg/models": "^1.0.0",
|
|
26
|
+
"mongoose": "^8.0.0",
|
|
27
|
+
"jaypie": "^1.1.0",
|
|
28
|
+
"express": "^4.19.0",
|
|
29
|
+
"express-oauth2-jwt-bearer": "^1.6.0",
|
|
30
|
+
"ajv": "^8.12.0",
|
|
31
|
+
"ajv-formats": "^2.1.1"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/express": "^4.17.0",
|
|
35
|
+
"@types/mongoose": "^8.0.0",
|
|
36
|
+
"typescript": "^5.0.0"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## TypeScript Configuration
|
|
42
|
+
|
|
43
|
+
**tsconfig.json**
|
|
44
|
+
```json
|
|
45
|
+
{
|
|
46
|
+
"compilerOptions": {
|
|
47
|
+
"target": "ES2022",
|
|
48
|
+
"module": "ESNext",
|
|
49
|
+
"moduleResolution": "node",
|
|
50
|
+
"declaration": true,
|
|
51
|
+
"outDir": "./dist",
|
|
52
|
+
"strict": true,
|
|
53
|
+
"esModuleInterop": true,
|
|
54
|
+
"skipLibCheck": true,
|
|
55
|
+
"forceConsistentCasingInFileNames": true,
|
|
56
|
+
"resolveJsonModule": true,
|
|
57
|
+
"allowSyntheticDefaultImports": true
|
|
58
|
+
},
|
|
59
|
+
"include": ["src/**/*", "index.ts"],
|
|
60
|
+
"exclude": ["node_modules", "dist", "**/*.spec.ts"]
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Handler Configuration
|
|
65
|
+
|
|
66
|
+
**src/handler.config.ts** - Database lifecycle management
|
|
67
|
+
```typescript
|
|
68
|
+
import { force } from "jaypie";
|
|
69
|
+
import Model from "@yourorg/models";
|
|
70
|
+
import systemContextLocal from "./util/systemContextLocal.js";
|
|
71
|
+
import userLocal from "./util/userLocal.js";
|
|
72
|
+
import validateAuth from "./util/validateAuth.js";
|
|
73
|
+
|
|
74
|
+
interface HandlerConfigOptions {
|
|
75
|
+
locals?: Record<string, any>;
|
|
76
|
+
setup?: Array<() => void | Promise<void>>;
|
|
77
|
+
teardown?: Array<() => void | Promise<void>>;
|
|
78
|
+
validate?: Array<() => boolean | Promise<boolean>>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface HandlerConfig {
|
|
82
|
+
name: string;
|
|
83
|
+
locals: Record<string, any>;
|
|
84
|
+
setup: Array<() => void | Promise<void>>;
|
|
85
|
+
teardown: Array<() => void | Promise<void>>;
|
|
86
|
+
validate: Array<() => boolean | Promise<boolean>>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const handlerConfig = (
|
|
90
|
+
nameOrConfig: string | (HandlerConfig & { name: string }),
|
|
91
|
+
options: HandlerConfigOptions = {}
|
|
92
|
+
): HandlerConfig => {
|
|
93
|
+
let name: string;
|
|
94
|
+
let locals: Record<string, any>;
|
|
95
|
+
let setup: Array<() => void | Promise<void>>;
|
|
96
|
+
let teardown: Array<() => void | Promise<void>>;
|
|
97
|
+
let validate: Array<() => boolean | Promise<boolean>>;
|
|
98
|
+
|
|
99
|
+
if (typeof nameOrConfig === "object") {
|
|
100
|
+
({ name, locals = {}, setup = [], teardown = [], validate = [] } = nameOrConfig);
|
|
101
|
+
} else {
|
|
102
|
+
name = nameOrConfig;
|
|
103
|
+
({ locals = {}, setup = [], teardown = [], validate = [] } = options);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
name,
|
|
108
|
+
locals: { ...force.object(locals), systemContext: systemContextLocal, user: userLocal },
|
|
109
|
+
setup: [Model.connect, ...force.array(setup)],
|
|
110
|
+
teardown: [...force.array(teardown), Model.disconnect],
|
|
111
|
+
validate: [validateAuth, ...force.array(validate)],
|
|
112
|
+
};
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export default handlerConfig;
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Authentication & Authorization
|
|
119
|
+
|
|
120
|
+
**src/util/validateAuth.ts** - JWT validation
|
|
121
|
+
```typescript
|
|
122
|
+
import { UnauthorizedError } from "jaypie";
|
|
123
|
+
import { auth } from "express-oauth2-jwt-bearer";
|
|
124
|
+
import type { Request, Response, NextFunction } from "express";
|
|
125
|
+
|
|
126
|
+
const jwtCheck = auth({
|
|
127
|
+
audience: process.env.AUTH0_AUDIENCE,
|
|
128
|
+
issuerBaseURL: process.env.AUTH0_ISSUER_BASE_URL,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
export default async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
|
132
|
+
return new Promise((resolve, reject) => {
|
|
133
|
+
jwtCheck(req, res, (err) => {
|
|
134
|
+
if (err) {
|
|
135
|
+
reject(new UnauthorizedError("Invalid token"));
|
|
136
|
+
} else {
|
|
137
|
+
resolve();
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
};
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
**src/util/userLocal.ts** - User context extraction
|
|
145
|
+
```typescript
|
|
146
|
+
import { UnauthorizedError } from "jaypie";
|
|
147
|
+
import type { Request } from "express";
|
|
148
|
+
|
|
149
|
+
interface UserContext {
|
|
150
|
+
sub: string;
|
|
151
|
+
email: string;
|
|
152
|
+
groups: string[];
|
|
153
|
+
portfolios: string[];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export default async (req: Request): Promise<UserContext> => {
|
|
157
|
+
const user = req.auth?.payload;
|
|
158
|
+
if (!user) {
|
|
159
|
+
throw new UnauthorizedError("User not authenticated");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
sub: user.sub,
|
|
164
|
+
email: user.email,
|
|
165
|
+
groups: user.groups || [],
|
|
166
|
+
portfolios: user.portfolios || [],
|
|
167
|
+
};
|
|
168
|
+
};
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
**src/util/authUserHasGroups.ts** - Group authorization
|
|
172
|
+
```typescript
|
|
173
|
+
import { ForbiddenError } from "jaypie";
|
|
174
|
+
|
|
175
|
+
interface AuthUserHasGroupsOptions {
|
|
176
|
+
requireGroups?: boolean;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
interface User {
|
|
180
|
+
groups?: string[];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export default (user: User, options: AuthUserHasGroupsOptions = {}): boolean => {
|
|
184
|
+
const { requireGroups = true } = options;
|
|
185
|
+
|
|
186
|
+
if (requireGroups && (!user.groups || user.groups.length === 0)) {
|
|
187
|
+
throw new ForbiddenError("User must belong to at least one group");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return true;
|
|
191
|
+
};
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
**src/util/authUserHasAllGroups.ts** - Specific group validation
|
|
195
|
+
```typescript
|
|
196
|
+
import { ForbiddenError } from "jaypie";
|
|
197
|
+
|
|
198
|
+
interface AuthUserHasAllGroupsOptions {
|
|
199
|
+
groupUuids: string[];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
interface User {
|
|
203
|
+
groups?: string[];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export default async (user: User, { groupUuids }: AuthUserHasAllGroupsOptions): Promise<boolean> => {
|
|
207
|
+
const userGroups = user.groups || [];
|
|
208
|
+
const hasAllGroups = groupUuids.every(groupUuid =>
|
|
209
|
+
userGroups.includes(groupUuid)
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
if (!hasAllGroups) {
|
|
213
|
+
throw new ForbiddenError("User lacks required group permissions");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return true;
|
|
217
|
+
};
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
## Database Operations
|
|
221
|
+
|
|
222
|
+
### Model Connection
|
|
223
|
+
```typescript
|
|
224
|
+
// Connect to MongoDB
|
|
225
|
+
await Model.connect();
|
|
226
|
+
|
|
227
|
+
// Disconnect from MongoDB
|
|
228
|
+
await Model.disconnect();
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### CRUD Operations
|
|
232
|
+
|
|
233
|
+
**Create with relationships**
|
|
234
|
+
```typescript
|
|
235
|
+
import Model from "@yourorg/models";
|
|
236
|
+
|
|
237
|
+
const Resource = Model.Resource;
|
|
238
|
+
const Group = Model.Group;
|
|
239
|
+
|
|
240
|
+
await Resource.createWithRelationships({
|
|
241
|
+
foreignKey: "resources",
|
|
242
|
+
models: { groups: Group },
|
|
243
|
+
uuid: resourceId,
|
|
244
|
+
values: {
|
|
245
|
+
name: "Resource Name",
|
|
246
|
+
description: "Resource Description",
|
|
247
|
+
groups: ["group-uuid-1", "group-uuid-2"]
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
**Read operations**
|
|
253
|
+
```typescript
|
|
254
|
+
// Find by UUID
|
|
255
|
+
const resource = await Model.Resource.oneByUuid(resourceId);
|
|
256
|
+
|
|
257
|
+
// Find by query
|
|
258
|
+
const resources = await Model.Resource.find({
|
|
259
|
+
name: { $regex: "search-term", $options: "i" }
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// Find with relationships
|
|
263
|
+
const resource = await Model.Resource.oneByUuid(resourceId, {
|
|
264
|
+
populate: ["groups", "portfolios"]
|
|
265
|
+
});
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
**Update with relationships**
|
|
269
|
+
```typescript
|
|
270
|
+
await Model.Resource.updateWithRelationships({
|
|
271
|
+
uuid: resourceId,
|
|
272
|
+
values: {
|
|
273
|
+
name: "Updated Name",
|
|
274
|
+
groups: ["new-group-uuid"]
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
**Delete operations**
|
|
280
|
+
```typescript
|
|
281
|
+
// Soft delete
|
|
282
|
+
await Model.Resource.deleteByUuid(resourceId);
|
|
283
|
+
|
|
284
|
+
// Hard delete
|
|
285
|
+
await Model.Resource.destroyByUuid(resourceId);
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
## CRUD Route Handlers
|
|
289
|
+
|
|
290
|
+
**src/routes/resource/resourceCreate.route.ts**
|
|
291
|
+
```typescript
|
|
292
|
+
import { cloneDeep, expressHandler, uuid } from "jaypie";
|
|
293
|
+
import Model from "@yourorg/models";
|
|
294
|
+
import handlerConfig from "../../handler.config.js";
|
|
295
|
+
import authUserHasGroups from "../../util/authUserHasGroups.js";
|
|
296
|
+
import formatMongoToJsonApi from "../../util/formatMongoToJsonApi.js";
|
|
297
|
+
import jsonApiValidator from "../../util/jsonApiValidator.js";
|
|
298
|
+
import resourceJsonSchema from "./resourceJson.schema.js";
|
|
299
|
+
import type { Request } from "express";
|
|
300
|
+
|
|
301
|
+
interface ResourceCreateRequest extends Request {
|
|
302
|
+
body: {
|
|
303
|
+
data: {
|
|
304
|
+
attributes: {
|
|
305
|
+
name: string;
|
|
306
|
+
description?: string;
|
|
307
|
+
groups?: string[];
|
|
308
|
+
portfolios?: string[];
|
|
309
|
+
};
|
|
310
|
+
};
|
|
311
|
+
};
|
|
312
|
+
locals: {
|
|
313
|
+
user: {
|
|
314
|
+
groups: string[];
|
|
315
|
+
};
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
interface ResourceCreateResponse {
|
|
320
|
+
data: {
|
|
321
|
+
type: string;
|
|
322
|
+
id: string;
|
|
323
|
+
attributes: Record<string, any>;
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export default expressHandler(
|
|
328
|
+
handlerConfig({
|
|
329
|
+
name: "resourceCreate",
|
|
330
|
+
validate: jsonApiValidator(resourceJsonSchema),
|
|
331
|
+
}),
|
|
332
|
+
async (req: ResourceCreateRequest): Promise<ResourceCreateResponse> => {
|
|
333
|
+
const { user } = req.locals;
|
|
334
|
+
|
|
335
|
+
authUserHasGroups(user);
|
|
336
|
+
|
|
337
|
+
const Resource = Model.Resource;
|
|
338
|
+
const Group = Model.Group;
|
|
339
|
+
const id = uuid();
|
|
340
|
+
|
|
341
|
+
const { groups = [] } = req.body.data.attributes;
|
|
342
|
+
if (!groups.length) {
|
|
343
|
+
groups.push(...(await Group.idsToUuids(user.groups)));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const createdResource = Object.assign(
|
|
347
|
+
{ name: "Default Resource" },
|
|
348
|
+
cloneDeep(req.body.data.attributes),
|
|
349
|
+
{ groups }
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
await Resource.createWithRelationships({
|
|
353
|
+
foreignKey: "resources",
|
|
354
|
+
models: { groups: Group },
|
|
355
|
+
uuid: id,
|
|
356
|
+
values: createdResource,
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
const returnResource = cloneDeep(createdResource);
|
|
360
|
+
returnResource.uuid = id;
|
|
361
|
+
return formatMongoToJsonApi(returnResource, resourceJsonSchema);
|
|
362
|
+
}
|
|
363
|
+
);
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
**src/routes/resource/resourceRead.route.ts**
|
|
367
|
+
```typescript
|
|
368
|
+
import { expressHandler, NotFoundError } from "jaypie";
|
|
369
|
+
import Model from "@yourorg/models";
|
|
370
|
+
import handlerConfig from "../../handler.config.js";
|
|
371
|
+
import authUserHasGroups from "../../util/authUserHasGroups.js";
|
|
372
|
+
import formatMongoToJsonApi from "../../util/formatMongoToJsonApi.js";
|
|
373
|
+
import resourceJsonSchema from "./resourceJson.schema.js";
|
|
374
|
+
import type { Request } from "express";
|
|
375
|
+
|
|
376
|
+
interface ResourceReadRequest extends Request {
|
|
377
|
+
params: {
|
|
378
|
+
id: string;
|
|
379
|
+
};
|
|
380
|
+
locals: {
|
|
381
|
+
user: {
|
|
382
|
+
groups: string[];
|
|
383
|
+
};
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
interface ResourceReadResponse {
|
|
388
|
+
data: {
|
|
389
|
+
type: string;
|
|
390
|
+
id: string;
|
|
391
|
+
attributes: Record<string, any>;
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export default expressHandler(
|
|
396
|
+
handlerConfig("resourceRead"),
|
|
397
|
+
async (req: ResourceReadRequest): Promise<ResourceReadResponse> => {
|
|
398
|
+
const { user } = req.locals;
|
|
399
|
+
const { id } = req.params;
|
|
400
|
+
|
|
401
|
+
authUserHasGroups(user);
|
|
402
|
+
|
|
403
|
+
const Resource = Model.Resource;
|
|
404
|
+
const resource = await Resource.oneByUuid(id);
|
|
405
|
+
|
|
406
|
+
if (!resource) {
|
|
407
|
+
throw new NotFoundError("Resource not found");
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Check user has access to resource groups
|
|
411
|
+
const hasAccess = resource.groups.some((group: string) =>
|
|
412
|
+
user.groups.includes(group)
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
if (!hasAccess) {
|
|
416
|
+
throw new NotFoundError("Resource not found");
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return formatMongoToJsonApi(resource, resourceJsonSchema);
|
|
420
|
+
}
|
|
421
|
+
);
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
**src/routes/resource/resourceUpdate.route.ts**
|
|
425
|
+
```typescript
|
|
426
|
+
import { cloneDeep, expressHandler, NotFoundError } from "jaypie";
|
|
427
|
+
import Model from "@yourorg/models";
|
|
428
|
+
import handlerConfig from "../../handler.config.js";
|
|
429
|
+
import authUserHasGroups from "../../util/authUserHasGroups.js";
|
|
430
|
+
import formatMongoToJsonApi from "../../util/formatMongoToJsonApi.js";
|
|
431
|
+
import jsonApiValidator from "../../util/jsonApiValidator.js";
|
|
432
|
+
import resourceJsonSchema from "./resourceJson.schema.js";
|
|
433
|
+
import type { Request } from "express";
|
|
434
|
+
|
|
435
|
+
interface ResourceUpdateRequest extends Request {
|
|
436
|
+
body: {
|
|
437
|
+
data: {
|
|
438
|
+
attributes: Record<string, any>;
|
|
439
|
+
};
|
|
440
|
+
};
|
|
441
|
+
params: {
|
|
442
|
+
id: string;
|
|
443
|
+
};
|
|
444
|
+
locals: {
|
|
445
|
+
user: {
|
|
446
|
+
groups: string[];
|
|
447
|
+
};
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
interface ResourceUpdateResponse {
|
|
452
|
+
data: {
|
|
453
|
+
type: string;
|
|
454
|
+
id: string;
|
|
455
|
+
attributes: Record<string, any>;
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export default expressHandler(
|
|
460
|
+
handlerConfig({
|
|
461
|
+
name: "resourceUpdate",
|
|
462
|
+
validate: jsonApiValidator(resourceJsonSchema),
|
|
463
|
+
}),
|
|
464
|
+
async (req: ResourceUpdateRequest): Promise<ResourceUpdateResponse> => {
|
|
465
|
+
const { user } = req.locals;
|
|
466
|
+
const { id } = req.params;
|
|
467
|
+
|
|
468
|
+
authUserHasGroups(user);
|
|
469
|
+
|
|
470
|
+
const Resource = Model.Resource;
|
|
471
|
+
const existingResource = await Resource.oneByUuid(id);
|
|
472
|
+
|
|
473
|
+
if (!existingResource) {
|
|
474
|
+
throw new NotFoundError("Resource not found");
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const updateData = cloneDeep(req.body.data.attributes);
|
|
478
|
+
|
|
479
|
+
await Resource.updateWithRelationships({
|
|
480
|
+
uuid: id,
|
|
481
|
+
values: updateData,
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
const updatedResource = await Resource.oneByUuid(id);
|
|
485
|
+
return formatMongoToJsonApi(updatedResource, resourceJsonSchema);
|
|
486
|
+
}
|
|
487
|
+
);
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
**src/routes/resource/resourceDelete.route.ts**
|
|
491
|
+
```typescript
|
|
492
|
+
import { expressHandler, NotFoundError } from "jaypie";
|
|
493
|
+
import Model from "@yourorg/models";
|
|
494
|
+
import handlerConfig from "../../handler.config.js";
|
|
495
|
+
import authUserHasGroups from "../../util/authUserHasGroups.js";
|
|
496
|
+
import type { Request } from "express";
|
|
497
|
+
|
|
498
|
+
interface ResourceDeleteRequest extends Request {
|
|
499
|
+
params: {
|
|
500
|
+
id: string;
|
|
501
|
+
};
|
|
502
|
+
locals: {
|
|
503
|
+
user: {
|
|
504
|
+
groups: string[];
|
|
505
|
+
};
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
export default expressHandler(
|
|
510
|
+
handlerConfig("resourceDelete"),
|
|
511
|
+
async (req: ResourceDeleteRequest): Promise<boolean> => {
|
|
512
|
+
const { user } = req.locals;
|
|
513
|
+
const { id } = req.params;
|
|
514
|
+
|
|
515
|
+
authUserHasGroups(user);
|
|
516
|
+
|
|
517
|
+
const Resource = Model.Resource;
|
|
518
|
+
const resource = await Resource.oneByUuid(id);
|
|
519
|
+
|
|
520
|
+
if (!resource) {
|
|
521
|
+
throw new NotFoundError("Resource not found");
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
await Resource.deleteByUuid(id);
|
|
525
|
+
return true; // 201 Created
|
|
526
|
+
}
|
|
527
|
+
);
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
**src/routes/resource/resourceIndex.route.ts**
|
|
531
|
+
```typescript
|
|
532
|
+
import { expressHandler } from "jaypie";
|
|
533
|
+
import Model from "@yourorg/models";
|
|
534
|
+
import handlerConfig from "../../handler.config.js";
|
|
535
|
+
import authUserHasGroups from "../../util/authUserHasGroups.js";
|
|
536
|
+
import formatMongoToJsonApi from "../../util/formatMongoToJsonApi.js";
|
|
537
|
+
import resourceJsonSchema from "./resourceJson.schema.js";
|
|
538
|
+
import type { Request } from "express";
|
|
539
|
+
|
|
540
|
+
interface ResourceIndexRequest extends Request {
|
|
541
|
+
query: {
|
|
542
|
+
limit?: string;
|
|
543
|
+
offset?: string;
|
|
544
|
+
search?: string;
|
|
545
|
+
};
|
|
546
|
+
locals: {
|
|
547
|
+
user: {
|
|
548
|
+
groups: string[];
|
|
549
|
+
};
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
interface ResourceIndexResponse {
|
|
554
|
+
data: Array<{
|
|
555
|
+
type: string;
|
|
556
|
+
id: string;
|
|
557
|
+
attributes: Record<string, any>;
|
|
558
|
+
}>;
|
|
559
|
+
meta: {
|
|
560
|
+
total: number;
|
|
561
|
+
limit: number;
|
|
562
|
+
offset: number;
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
export default expressHandler(
|
|
567
|
+
handlerConfig("resourceIndex"),
|
|
568
|
+
async (req: ResourceIndexRequest): Promise<ResourceIndexResponse> => {
|
|
569
|
+
const { user } = req.locals;
|
|
570
|
+
const { limit = "20", offset = "0", search } = req.query;
|
|
571
|
+
|
|
572
|
+
authUserHasGroups(user);
|
|
573
|
+
|
|
574
|
+
const Resource = Model.Resource;
|
|
575
|
+
let query: Record<string, any> = {
|
|
576
|
+
groups: { $in: user.groups }
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
if (search) {
|
|
580
|
+
query.name = { $regex: search, $options: "i" };
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const resources = await Resource.find(query)
|
|
584
|
+
.limit(parseInt(limit))
|
|
585
|
+
.skip(parseInt(offset))
|
|
586
|
+
.sort({ createdAt: -1 });
|
|
587
|
+
|
|
588
|
+
const total = await Resource.countDocuments(query);
|
|
589
|
+
|
|
590
|
+
return {
|
|
591
|
+
data: resources.map(resource =>
|
|
592
|
+
formatMongoToJsonApi(resource, resourceJsonSchema)
|
|
593
|
+
),
|
|
594
|
+
meta: {
|
|
595
|
+
total,
|
|
596
|
+
limit: parseInt(limit),
|
|
597
|
+
offset: parseInt(offset),
|
|
598
|
+
},
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
);
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
## JSON Schema & Validation
|
|
605
|
+
|
|
606
|
+
**src/routes/resource/resourceJson.schema.ts**
|
|
607
|
+
```typescript
|
|
608
|
+
interface ResourceJsonSchema {
|
|
609
|
+
type: string;
|
|
610
|
+
properties: {
|
|
611
|
+
data: {
|
|
612
|
+
type: string;
|
|
613
|
+
properties: {
|
|
614
|
+
type: {
|
|
615
|
+
type: string;
|
|
616
|
+
enum: string[];
|
|
617
|
+
};
|
|
618
|
+
attributes: {
|
|
619
|
+
type: string;
|
|
620
|
+
properties: {
|
|
621
|
+
name: {
|
|
622
|
+
type: string;
|
|
623
|
+
minLength: number;
|
|
624
|
+
maxLength: number;
|
|
625
|
+
};
|
|
626
|
+
description: {
|
|
627
|
+
type: string;
|
|
628
|
+
maxLength: number;
|
|
629
|
+
};
|
|
630
|
+
groups: {
|
|
631
|
+
type: string;
|
|
632
|
+
items: {
|
|
633
|
+
type: string;
|
|
634
|
+
format: string;
|
|
635
|
+
};
|
|
636
|
+
};
|
|
637
|
+
portfolios: {
|
|
638
|
+
type: string;
|
|
639
|
+
items: {
|
|
640
|
+
type: string;
|
|
641
|
+
format: string;
|
|
642
|
+
};
|
|
643
|
+
};
|
|
644
|
+
};
|
|
645
|
+
required: string[];
|
|
646
|
+
additionalProperties: boolean;
|
|
647
|
+
};
|
|
648
|
+
};
|
|
649
|
+
required: string[];
|
|
650
|
+
additionalProperties: boolean;
|
|
651
|
+
};
|
|
652
|
+
};
|
|
653
|
+
required: string[];
|
|
654
|
+
additionalProperties: boolean;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const resourceJsonSchema: ResourceJsonSchema = {
|
|
658
|
+
type: "object",
|
|
659
|
+
properties: {
|
|
660
|
+
data: {
|
|
661
|
+
type: "object",
|
|
662
|
+
properties: {
|
|
663
|
+
type: {
|
|
664
|
+
type: "string",
|
|
665
|
+
enum: ["resource"],
|
|
666
|
+
},
|
|
667
|
+
attributes: {
|
|
668
|
+
type: "object",
|
|
669
|
+
properties: {
|
|
670
|
+
name: {
|
|
671
|
+
type: "string",
|
|
672
|
+
minLength: 1,
|
|
673
|
+
maxLength: 255,
|
|
674
|
+
},
|
|
675
|
+
description: {
|
|
676
|
+
type: "string",
|
|
677
|
+
maxLength: 1000,
|
|
678
|
+
},
|
|
679
|
+
groups: {
|
|
680
|
+
type: "array",
|
|
681
|
+
items: {
|
|
682
|
+
type: "string",
|
|
683
|
+
format: "uuid",
|
|
684
|
+
},
|
|
685
|
+
},
|
|
686
|
+
portfolios: {
|
|
687
|
+
type: "array",
|
|
688
|
+
items: {
|
|
689
|
+
type: "string",
|
|
690
|
+
format: "uuid",
|
|
691
|
+
},
|
|
692
|
+
},
|
|
693
|
+
},
|
|
694
|
+
required: ["name"],
|
|
695
|
+
additionalProperties: false,
|
|
696
|
+
},
|
|
697
|
+
},
|
|
698
|
+
required: ["type", "attributes"],
|
|
699
|
+
additionalProperties: false,
|
|
700
|
+
},
|
|
701
|
+
},
|
|
702
|
+
required: ["data"],
|
|
703
|
+
additionalProperties: false,
|
|
704
|
+
};
|
|
705
|
+
|
|
706
|
+
export default resourceJsonSchema;
|
|
707
|
+
```
|
|
708
|
+
|
|
709
|
+
## Utility Functions
|
|
710
|
+
|
|
711
|
+
**src/util/formatMongoToJsonApi.ts**
|
|
712
|
+
```typescript
|
|
713
|
+
interface MongoDoc {
|
|
714
|
+
toObject?: () => Record<string, any>;
|
|
715
|
+
[key: string]: any;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
interface JsonApiSchema {
|
|
719
|
+
properties: {
|
|
720
|
+
data: {
|
|
721
|
+
properties: {
|
|
722
|
+
type: {
|
|
723
|
+
enum: string[];
|
|
724
|
+
};
|
|
725
|
+
};
|
|
726
|
+
};
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
interface JsonApiResponse {
|
|
731
|
+
data: {
|
|
732
|
+
type: string;
|
|
733
|
+
id: string;
|
|
734
|
+
attributes: Record<string, any>;
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
export default (mongoDoc: MongoDoc, schema: JsonApiSchema): JsonApiResponse => {
|
|
739
|
+
const { _id, __v, ...attributes } = mongoDoc.toObject ? mongoDoc.toObject() : mongoDoc;
|
|
740
|
+
|
|
741
|
+
return {
|
|
742
|
+
data: {
|
|
743
|
+
type: schema.properties.data.properties.type.enum[0],
|
|
744
|
+
id: attributes.uuid,
|
|
745
|
+
attributes: {
|
|
746
|
+
...attributes,
|
|
747
|
+
id: undefined,
|
|
748
|
+
uuid: undefined,
|
|
749
|
+
},
|
|
750
|
+
},
|
|
751
|
+
};
|
|
752
|
+
};
|
|
753
|
+
```
|
|
754
|
+
|
|
755
|
+
**src/util/jsonApiValidator.ts**
|
|
756
|
+
```typescript
|
|
757
|
+
import Ajv from "ajv";
|
|
758
|
+
import addFormats from "ajv-formats";
|
|
759
|
+
import { BadRequestError } from "jaypie";
|
|
760
|
+
import type { Request } from "express";
|
|
761
|
+
|
|
762
|
+
interface ValidationError {
|
|
763
|
+
field: string;
|
|
764
|
+
message: string;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const ajv = new Ajv({ allErrors: true });
|
|
768
|
+
addFormats(ajv);
|
|
769
|
+
|
|
770
|
+
export default (schema: object) => {
|
|
771
|
+
const validate = ajv.compile(schema);
|
|
772
|
+
|
|
773
|
+
return (req: Request): boolean => {
|
|
774
|
+
const valid = validate(req.body);
|
|
775
|
+
|
|
776
|
+
if (!valid) {
|
|
777
|
+
const errors: ValidationError[] = validate.errors?.map(error => ({
|
|
778
|
+
field: error.instancePath,
|
|
779
|
+
message: error.message || "Validation error",
|
|
780
|
+
})) || [];
|
|
781
|
+
|
|
782
|
+
throw new BadRequestError("Validation failed", { errors });
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
return true;
|
|
786
|
+
};
|
|
787
|
+
};
|
|
788
|
+
```
|
|
789
|
+
|
|
790
|
+
## Testing Setup
|
|
791
|
+
|
|
792
|
+
**vitest.config.ts**
|
|
793
|
+
```typescript
|
|
794
|
+
import { defineConfig } from "vitest/config";
|
|
795
|
+
|
|
796
|
+
export default defineConfig({
|
|
797
|
+
test: {
|
|
798
|
+
setupFiles: ["./vitest.setup.ts"],
|
|
799
|
+
},
|
|
800
|
+
});
|
|
801
|
+
```
|
|
802
|
+
|
|
803
|
+
**vitest.setup.ts** - MongoDB mocking
|
|
804
|
+
```typescript
|
|
805
|
+
import Model from "@yourorg/models";
|
|
806
|
+
import { matchers as jaypieMatchers } from "@jaypie/testkit";
|
|
807
|
+
import * as extendedMatchers from "jest-extended";
|
|
808
|
+
import { expect, vi } from "vitest";
|
|
809
|
+
|
|
810
|
+
expect.extend(extendedMatchers);
|
|
811
|
+
expect.extend(jaypieMatchers);
|
|
812
|
+
|
|
813
|
+
vi.mock("@yourorg/models");
|
|
814
|
+
vi.mock("jaypie", async () => vi.importActual("@jaypie/testkit/mock"));
|
|
815
|
+
vi.mock("./src/util/userLocal.js");
|
|
816
|
+
vi.mock("./src/util/validateAuth.js");
|
|
817
|
+
|
|
818
|
+
// Mock Model methods
|
|
819
|
+
Model.connect = vi.fn();
|
|
820
|
+
Model.disconnect = vi.fn();
|
|
821
|
+
|
|
822
|
+
Model.mock.Resource.oneByUuid.mockResolvedValue({
|
|
823
|
+
_id: "resource000000000000000",
|
|
824
|
+
name: "Mock Resource",
|
|
825
|
+
uuid: "MOCK_RESOURCE_UUID",
|
|
826
|
+
groups: ["group000000000000000"],
|
|
827
|
+
toObject: () => ({
|
|
828
|
+
name: "Mock Resource",
|
|
829
|
+
uuid: "MOCK_RESOURCE_UUID",
|
|
830
|
+
groups: ["group000000000000000"],
|
|
831
|
+
}),
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
Model.mock.Resource.find.mockResolvedValue([
|
|
835
|
+
{
|
|
836
|
+
_id: "resource000000000000001",
|
|
837
|
+
name: "Mock Resource 1",
|
|
838
|
+
uuid: "MOCK_RESOURCE_UUID_1",
|
|
839
|
+
groups: ["group000000000000000"],
|
|
840
|
+
},
|
|
841
|
+
]);
|
|
842
|
+
|
|
843
|
+
Model.mock.Resource.createWithRelationships.mockResolvedValue({
|
|
844
|
+
_id: "resource000000000000002",
|
|
845
|
+
name: "Created Resource",
|
|
846
|
+
uuid: "CREATED_RESOURCE_UUID",
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
Model.mock.Group.idsToUuids.mockResolvedValue([
|
|
850
|
+
"group000000000000000",
|
|
851
|
+
]);
|
|
852
|
+
```
|
|
853
|
+
|
|
854
|
+
## Test Examples
|
|
855
|
+
|
|
856
|
+
**Unit Test**
|
|
857
|
+
```typescript
|
|
858
|
+
import { uuid } from "jaypie";
|
|
859
|
+
import { describe, expect, it } from "vitest";
|
|
860
|
+
import Model from "@yourorg/models";
|
|
861
|
+
import resourceCreate from "../resourceCreate.route.js";
|
|
862
|
+
|
|
863
|
+
uuid.mockReturnValue("generated-uuid");
|
|
864
|
+
|
|
865
|
+
describe("Resource Create Route", () => {
|
|
866
|
+
it("creates resource with relationships", async () => {
|
|
867
|
+
const mockRequest = {
|
|
868
|
+
body: {
|
|
869
|
+
data: {
|
|
870
|
+
type: "resource",
|
|
871
|
+
attributes: {
|
|
872
|
+
name: "Test Resource",
|
|
873
|
+
groups: ["00000000-0000-0000-0000-000000000000"],
|
|
874
|
+
},
|
|
875
|
+
},
|
|
876
|
+
},
|
|
877
|
+
locals: {
|
|
878
|
+
user: {
|
|
879
|
+
sub: "user123",
|
|
880
|
+
groups: ["00000000-0000-0000-0000-000000000000"],
|
|
881
|
+
},
|
|
882
|
+
},
|
|
883
|
+
} as any;
|
|
884
|
+
|
|
885
|
+
const response = await resourceCreate(mockRequest);
|
|
886
|
+
|
|
887
|
+
expect(Model.Resource.createWithRelationships).toHaveBeenCalledWith({
|
|
888
|
+
foreignKey: "resources",
|
|
889
|
+
models: { groups: Model.Group },
|
|
890
|
+
uuid: "generated-uuid",
|
|
891
|
+
values: expect.objectContaining({
|
|
892
|
+
name: "Test Resource",
|
|
893
|
+
groups: ["00000000-0000-0000-0000-000000000000"],
|
|
894
|
+
}),
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
expect(response.data.id).toBe("generated-uuid");
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
it("throws when user has no groups", async () => {
|
|
901
|
+
const mockRequest = {
|
|
902
|
+
body: {
|
|
903
|
+
data: {
|
|
904
|
+
type: "resource",
|
|
905
|
+
attributes: { name: "Test Resource" },
|
|
906
|
+
},
|
|
907
|
+
},
|
|
908
|
+
locals: {
|
|
909
|
+
user: { sub: "user123", groups: [] },
|
|
910
|
+
},
|
|
911
|
+
} as any;
|
|
912
|
+
|
|
913
|
+
await expect(() => resourceCreate(mockRequest)).toThrowForbiddenError();
|
|
914
|
+
});
|
|
915
|
+
});
|
|
916
|
+
```
|
|
917
|
+
|
|
918
|
+
## Connection Management
|
|
919
|
+
|
|
920
|
+
### Environment Variables
|
|
921
|
+
```bash
|
|
922
|
+
MONGODB_URI=mongodb://localhost:27017/myapp
|
|
923
|
+
MONGODB_DB_NAME=myapp
|
|
924
|
+
```
|
|
925
|
+
|
|
926
|
+
### Model Configuration
|
|
927
|
+
```typescript
|
|
928
|
+
// @yourorg/models package structure
|
|
929
|
+
export default {
|
|
930
|
+
connect: async () => {
|
|
931
|
+
await mongoose.connect(process.env.MONGODB_URI);
|
|
932
|
+
},
|
|
933
|
+
disconnect: async () => {
|
|
934
|
+
await mongoose.disconnect();
|
|
935
|
+
},
|
|
936
|
+
Resource: ResourceModel,
|
|
937
|
+
Group: GroupModel,
|
|
938
|
+
Portfolio: PortfolioModel,
|
|
939
|
+
};
|
|
940
|
+
```
|
|
941
|
+
|
|
942
|
+
## Error Handling
|
|
943
|
+
|
|
944
|
+
### Database Errors
|
|
945
|
+
```typescript
|
|
946
|
+
import { InternalError, BadRequestError } from "jaypie";
|
|
947
|
+
|
|
948
|
+
try {
|
|
949
|
+
await Model.Resource.createWithRelationships(data);
|
|
950
|
+
} catch (error: any) {
|
|
951
|
+
if (error.code === 11000) {
|
|
952
|
+
// Duplicate key error
|
|
953
|
+
throw new BadRequestError("Resource already exists");
|
|
954
|
+
}
|
|
955
|
+
throw new InternalError("Database operation failed");
|
|
956
|
+
}
|
|
957
|
+
```
|
|
958
|
+
|
|
959
|
+
### Validation Errors
|
|
960
|
+
```typescript
|
|
961
|
+
// JSON Schema validation automatically throws BadRequestError
|
|
962
|
+
// Custom validation in routes:
|
|
963
|
+
if (!resourceData.name || resourceData.name.trim() === "") {
|
|
964
|
+
throw new BadRequestError("Resource name is required");
|
|
965
|
+
}
|
|
966
|
+
```
|
|
967
|
+
|
|
968
|
+
## Performance Considerations
|
|
969
|
+
|
|
970
|
+
### Indexing
|
|
971
|
+
```typescript
|
|
972
|
+
// In your MongoDB models
|
|
973
|
+
resourceSchema.index({ uuid: 1 }, { unique: true });
|
|
974
|
+
resourceSchema.index({ groups: 1 });
|
|
975
|
+
resourceSchema.index({ name: "text" });
|
|
976
|
+
```
|
|
977
|
+
|
|
978
|
+
### Query Optimization
|
|
979
|
+
```typescript
|
|
980
|
+
// Use projection to limit fields
|
|
981
|
+
const resources = await Resource.find(query, {
|
|
982
|
+
name: 1,
|
|
983
|
+
uuid: 1,
|
|
984
|
+
groups: 1,
|
|
985
|
+
createdAt: 1,
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
// Use lean() for read-only operations
|
|
989
|
+
const resources = await Resource.find(query).lean();
|
|
990
|
+
```
|
|
991
|
+
|
|
992
|
+
### Connection Pooling
|
|
993
|
+
```typescript
|
|
994
|
+
// In Model.connect()
|
|
995
|
+
await mongoose.connect(process.env.MONGODB_URI, {
|
|
996
|
+
maxPoolSize: 10,
|
|
997
|
+
serverSelectionTimeoutMS: 5000,
|
|
998
|
+
socketTimeoutMS: 45000,
|
|
999
|
+
});
|
|
1000
|
+
```
|