Skip to content

Commit 43e4e2d

Browse files
committed
use a single boolean to enable nestedRoutes
1 parent 46dfff9 commit 43e4e2d

File tree

3 files changed

+18
-277
lines changed

3 files changed

+18
-277
lines changed

packages/server/src/api/rest/index.ts

Lines changed: 14 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -66,26 +66,13 @@ export type RestApiHandlerOptions<Schema extends SchemaDef = SchemaDef> = {
6666
externalIdMapping?: Record<string, string>;
6767

6868
/**
69-
* Explicit nested route configuration.
69+
* When `true`, enables nested route handling for all to-many relations:
70+
* `/:parentType/:parentId/:relationName` (collection) and
71+
* `/:parentType/:parentId/:relationName/:childId` (single).
7072
*
71-
* First-level keys are parent model names, second-level keys are relation field names on the parent model
72-
* (e.g., `posts` for `User.posts`). This matches the URL segment used in nested routes:
73-
* `/:parentType/:parentId/:relationName` and `/:parentType/:parentId/:relationName/:childId`.
73+
* Defaults to `false`.
7474
*/
75-
nestedRoutes?: Record<
76-
string,
77-
Record<
78-
string,
79-
{
80-
/**
81-
* When `true`, the constructor throws if the configured relation does not have an `onDelete`
82-
* action of `Cascade`, `Restrict`, or `NoAction` in the schema. This ensures the database
83-
* prevents orphaned child records when a parent is deleted.
84-
*/
85-
requireOrphanProtection?: boolean;
86-
}
87-
>
88-
>;
75+
nestedRoutes?: boolean;
8976
};
9077

9178
type RelationshipInfo = {
@@ -286,7 +273,7 @@ export class RestApiHandler<Schema extends SchemaDef = SchemaDef> implements Api
286273
private modelNameMapping: Record<string, string>;
287274
private reverseModelNameMapping: Record<string, string>;
288275
private externalIdMapping: Record<string, string>;
289-
private nestedRoutes: Record<string, Record<string, { requireOrphanProtection?: boolean }>>;
276+
private nestedRoutes: boolean;
290277

291278
constructor(private readonly options: RestApiHandlerOptions<Schema>) {
292279
this.validateOptions(options);
@@ -307,20 +294,11 @@ export class RestApiHandler<Schema extends SchemaDef = SchemaDef> implements Api
307294
Object.entries(this.externalIdMapping).map(([k, v]) => [lowerCaseFirst(k), v]),
308295
);
309296

310-
this.nestedRoutes = options.nestedRoutes ?? {};
311-
this.nestedRoutes = Object.fromEntries(
312-
Object.entries(this.nestedRoutes).map(([parentModel, children]) => [
313-
lowerCaseFirst(parentModel),
314-
Object.fromEntries(
315-
Object.entries(children).map(([childModel, config]) => [lowerCaseFirst(childModel), config]),
316-
),
317-
]),
318-
);
297+
this.nestedRoutes = options.nestedRoutes ?? false;
319298

320299
this.urlPatternMap = this.buildUrlPatternMap(segmentCharset);
321300

322301
this.buildTypeMap();
323-
this.validateNestedRoutes();
324302
this.buildSerializers();
325303
}
326304

@@ -334,55 +312,14 @@ export class RestApiHandler<Schema extends SchemaDef = SchemaDef> implements Api
334312
urlSegmentCharset: z.string().min(1).optional(),
335313
modelNameMapping: z.record(z.string(), z.string()).optional(),
336314
externalIdMapping: z.record(z.string(), z.string()).optional(),
337-
nestedRoutes: z
338-
.record(z.string(), z.record(z.string(), z.object({ requireOrphanProtection: z.boolean().optional() })))
339-
.optional(),
315+
nestedRoutes: z.boolean().optional(),
340316
});
341317
const parseResult = schema.safeParse(options);
342318
if (!parseResult.success) {
343319
throw new Error(`Invalid options: ${fromError(parseResult.error)}`);
344320
}
345321
}
346322

347-
private validateNestedRoutes() {
348-
for (const [parentModel, relations] of Object.entries(this.nestedRoutes)) {
349-
const parentInfo = this.getModelInfo(parentModel);
350-
if (!parentInfo) {
351-
throw new Error(`Invalid nestedRoutes: parent model "${parentModel}" not found in schema`);
352-
}
353-
for (const [relationName, config] of Object.entries(relations)) {
354-
const parentField: FieldDef | undefined = this.schema.models[parentInfo.name]?.fields[relationName];
355-
if (!parentField?.relation) {
356-
throw new Error(
357-
`Invalid nestedRoutes: relation "${relationName}" not found on parent model "${parentModel}"`,
358-
);
359-
}
360-
const reverseRelation = parentField.relation.opposite;
361-
if (!reverseRelation) {
362-
throw new Error(
363-
`Invalid nestedRoutes: relation "${parentModel}.${relationName}" has no opposite relation defined`,
364-
);
365-
}
366-
if (!parentField.array) {
367-
throw new Error(
368-
`Invalid nestedRoutes: relation "${parentModel}.${relationName}" is a to-one relation — nested routes only support to-many relations`,
369-
);
370-
}
371-
if (config.requireOrphanProtection) {
372-
const childModelName = parentField.type;
373-
const onDelete = this.schema.models[childModelName]?.fields[reverseRelation]?.relation?.onDelete;
374-
const safeActions = ['Cascade', 'Restrict', 'NoAction'];
375-
if (!onDelete || !safeActions.includes(onDelete)) {
376-
throw new Error(
377-
`Invalid nestedRoutes: requireOrphanProtection is enabled for "${parentModel}.${relationName}" ` +
378-
`but its onDelete action is "${onDelete ?? 'not set'}" — must be Cascade, Restrict, or NoAction`,
379-
);
380-
}
381-
}
382-
}
383-
}
384-
}
385-
386323
get schema() {
387324
return this.options.schema;
388325
}
@@ -417,10 +354,6 @@ export class RestApiHandler<Schema extends SchemaDef = SchemaDef> implements Api
417354
return this.modelNameMapping[modelName] ?? modelName;
418355
}
419356

420-
private getNestedRouteConfig(parentType: string, parentRelation: string) {
421-
return this.nestedRoutes[lowerCaseFirst(parentType)]?.[parentRelation];
422-
}
423-
424357
/**
425358
* Resolves child model type and reverse relation from a parent relation name.
426359
* e.g. given parentType='user', parentRelation='posts', returns { childType:'post', reverseRelation:'author' }
@@ -548,7 +481,7 @@ export class RestApiHandler<Schema extends SchemaDef = SchemaDef> implements Api
548481

549482
// /:type/:id/:relationship/:childId — nested single read
550483
match = this.matchUrlPattern(path, UrlPatterns.NESTED_SINGLE);
551-
if (match && this.getNestedRouteConfig(match.type, match.relationship)) {
484+
if (match && this.nestedRoutes && this.resolveNestedRelation(match.type, match.relationship)?.isCollection) {
552485
return await this.processNestedSingleRead(
553486
client,
554487
match.type,
@@ -573,7 +506,7 @@ export class RestApiHandler<Schema extends SchemaDef = SchemaDef> implements Api
573506
}
574507
// /:type/:id/:relationship — nested create
575508
const nestedMatch = this.matchUrlPattern(path, UrlPatterns.FETCH_RELATIONSHIP);
576-
if (nestedMatch && this.getNestedRouteConfig(nestedMatch.type, nestedMatch.relationship)) {
509+
if (nestedMatch && this.nestedRoutes && this.resolveNestedRelation(nestedMatch.type, nestedMatch.relationship)?.isCollection) {
577510
return await this.processNestedCreate(
578511
client,
579512
nestedMatch.type,
@@ -636,7 +569,8 @@ export class RestApiHandler<Schema extends SchemaDef = SchemaDef> implements Api
636569
const nestedPatchMatch = this.matchUrlPattern(path, UrlPatterns.NESTED_SINGLE);
637570
if (
638571
nestedPatchMatch &&
639-
this.getNestedRouteConfig(nestedPatchMatch.type, nestedPatchMatch.relationship)
572+
this.nestedRoutes &&
573+
this.resolveNestedRelation(nestedPatchMatch.type, nestedPatchMatch.relationship)?.isCollection
640574
) {
641575
return await this.processNestedUpdate(
642576
client,
@@ -675,7 +609,8 @@ export class RestApiHandler<Schema extends SchemaDef = SchemaDef> implements Api
675609
const nestedDeleteMatch = this.matchUrlPattern(path, UrlPatterns.NESTED_SINGLE);
676610
if (
677611
nestedDeleteMatch &&
678-
this.getNestedRouteConfig(nestedDeleteMatch.type, nestedDeleteMatch.relationship)
612+
this.nestedRoutes &&
613+
this.resolveNestedRelation(nestedDeleteMatch.type, nestedDeleteMatch.relationship)?.isCollection
679614
) {
680615
return await this.processNestedDelete(
681616
client,

packages/server/test/api/options-validation.test.ts

Lines changed: 1 addition & 187 deletions
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ describe('API Handler Options Validation', () => {
202202
}).toThrow('Invalid options');
203203
});
204204

205-
it('should throw error when nestedRoutes is not an object', () => {
205+
it('should throw error when nestedRoutes is not a boolean', () => {
206206
expect(() => {
207207
new RestApiHandler({
208208
schema: client.$schema,
@@ -212,192 +212,6 @@ describe('API Handler Options Validation', () => {
212212
}).toThrow('Invalid options');
213213
});
214214

215-
it('should throw error when nestedRoutes config value is invalid type', () => {
216-
expect(() => {
217-
new RestApiHandler({
218-
schema: client.$schema,
219-
endpoint: 'http://localhost/api',
220-
nestedRoutes: {
221-
User: {
222-
posts: {
223-
requireOrphanProtection: 'yes',
224-
},
225-
},
226-
} as any,
227-
});
228-
}).toThrow('Invalid options');
229-
});
230-
231-
describe('nestedRoutes semantic validation', () => {
232-
let relClient: ClientContract<SchemaDef>;
233-
234-
const relSchema = `
235-
model User {
236-
id String @id @default(cuid())
237-
email String @unique
238-
posts Post[]
239-
}
240-
model Post {
241-
id Int @id @default(autoincrement())
242-
title String
243-
author User @relation(fields: [authorId], references: [id])
244-
authorId String
245-
}
246-
`;
247-
248-
beforeEach(async () => {
249-
relClient = await createTestClient(relSchema);
250-
});
251-
252-
it('should throw when parent model does not exist in schema', () => {
253-
expect(() => {
254-
new RestApiHandler({
255-
schema: relClient.$schema,
256-
endpoint: 'http://localhost/api',
257-
nestedRoutes: {
258-
NonExistent: { posts: {} },
259-
},
260-
});
261-
}).toThrow('Invalid nestedRoutes');
262-
});
263-
264-
it('should throw when relation field does not exist on parent model', () => {
265-
expect(() => {
266-
new RestApiHandler({
267-
schema: relClient.$schema,
268-
endpoint: 'http://localhost/api',
269-
nestedRoutes: {
270-
User: { nonExistentRelation: {} },
271-
},
272-
});
273-
}).toThrow('Invalid nestedRoutes');
274-
});
275-
276-
it('should throw when relation is to-one', () => {
277-
expect(() => {
278-
new RestApiHandler({
279-
schema: relClient.$schema,
280-
endpoint: 'http://localhost/api',
281-
nestedRoutes: {
282-
Post: { author: {} },
283-
},
284-
});
285-
}).toThrow('Invalid nestedRoutes');
286-
});
287-
288-
it('should accept valid to-many nestedRoutes configuration', () => {
289-
expect(() => {
290-
new RestApiHandler({
291-
schema: relClient.$schema,
292-
endpoint: 'http://localhost/api',
293-
nestedRoutes: {
294-
User: { posts: {} },
295-
},
296-
});
297-
}).not.toThrow();
298-
});
299-
300-
describe('requireOrphanProtection', () => {
301-
it('should throw when requireOrphanProtection is true and onDelete is not set', () => {
302-
// relSchema has no onDelete on Post.author
303-
expect(() => {
304-
new RestApiHandler({
305-
schema: relClient.$schema,
306-
endpoint: 'http://localhost/api',
307-
nestedRoutes: {
308-
User: { posts: { requireOrphanProtection: true } },
309-
},
310-
});
311-
}).toThrow('requireOrphanProtection');
312-
});
313-
314-
it('should throw when requireOrphanProtection is true and onDelete is SetNull', async () => {
315-
const c = await createTestClient(`
316-
model User {
317-
id String @id @default(cuid())
318-
posts Post[]
319-
}
320-
model Post {
321-
id Int @id @default(autoincrement())
322-
title String
323-
author User? @relation(fields: [authorId], references: [id], onDelete: SetNull)
324-
authorId String?
325-
}
326-
`);
327-
expect(() => {
328-
new RestApiHandler({
329-
schema: c.$schema,
330-
endpoint: 'http://localhost/api',
331-
nestedRoutes: {
332-
User: { posts: { requireOrphanProtection: true } },
333-
},
334-
});
335-
}).toThrow('requireOrphanProtection');
336-
});
337-
338-
it('should accept when requireOrphanProtection is true and onDelete is Cascade', async () => {
339-
const c = await createTestClient(`
340-
model User {
341-
id String @id @default(cuid())
342-
posts Post[]
343-
}
344-
model Post {
345-
id Int @id @default(autoincrement())
346-
title String
347-
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
348-
authorId String
349-
}
350-
`);
351-
expect(() => {
352-
new RestApiHandler({
353-
schema: c.$schema,
354-
endpoint: 'http://localhost/api',
355-
nestedRoutes: {
356-
User: { posts: { requireOrphanProtection: true } },
357-
},
358-
});
359-
}).not.toThrow();
360-
});
361-
362-
it('should accept when requireOrphanProtection is true and onDelete is Restrict', async () => {
363-
const c = await createTestClient(`
364-
model User {
365-
id String @id @default(cuid())
366-
posts Post[]
367-
}
368-
model Post {
369-
id Int @id @default(autoincrement())
370-
title String
371-
author User @relation(fields: [authorId], references: [id], onDelete: Restrict)
372-
authorId String
373-
}
374-
`);
375-
expect(() => {
376-
new RestApiHandler({
377-
schema: c.$schema,
378-
endpoint: 'http://localhost/api',
379-
nestedRoutes: {
380-
User: { posts: { requireOrphanProtection: true } },
381-
},
382-
});
383-
}).not.toThrow();
384-
});
385-
386-
it('should not check orphan protection when requireOrphanProtection is not set', () => {
387-
// relSchema has no onDelete — still fine without the flag
388-
expect(() => {
389-
new RestApiHandler({
390-
schema: relClient.$schema,
391-
endpoint: 'http://localhost/api',
392-
nestedRoutes: {
393-
User: { posts: {} },
394-
},
395-
});
396-
}).not.toThrow();
397-
});
398-
});
399-
});
400-
401215
it('should throw error when log is invalid type', () => {
402216
expect(() => {
403217
new RestApiHandler({

0 commit comments

Comments
 (0)