Skip to content

Commit

Permalink
feat: add shorthand {} for sub-routers (#3744)
Browse files Browse the repository at this point in the history
  • Loading branch information
Nsttt committed Mar 10, 2023
1 parent da05be2 commit 9c6ff8f
Show file tree
Hide file tree
Showing 3 changed files with 222 additions and 18 deletions.
47 changes: 41 additions & 6 deletions packages/server/src/core/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
export type ProcedureRecord = Record<string, AnyProcedure>;

export interface ProcedureRouterRecord {
[key: string]: AnyProcedure | AnyRouter;
[key: string]: AnyProcedure | AnyRouter | ProcedureRouterRecord;
}

/**
Expand Down Expand Up @@ -97,7 +97,9 @@ type DecorateProcedure<TProcedure extends AnyProcedure> = (
* @internal
*/
type DecoratedProcedureRecord<TProcedures extends ProcedureRouterRecord> = {
[TKey in keyof TProcedures]: TProcedures[TKey] extends AnyRouter
[TKey in keyof TProcedures]: TProcedures[TKey] extends ProcedureRouterRecord
? DecoratedProcedureRecord<TProcedures[TKey]>
: TProcedures[TKey] extends AnyRouter
? DecoratedProcedureRecord<TProcedures[TKey]['_def']['record']>
: TProcedures[TKey] extends AnyProcedure
? DecorateProcedure<TProcedures[TKey]>
Expand Down Expand Up @@ -140,11 +142,17 @@ export interface Router<TDef extends AnyRouterDef> {
export type AnyRouter = Router<AnyRouterDef>;

function isRouter(
procedureOrRouter: AnyProcedure | AnyRouter,
procedureOrRouter: AnyProcedure | AnyRouter | ProcedureRouterRecord,
): procedureOrRouter is AnyRouter {
return 'router' in procedureOrRouter._def;
}

function isNestedRouter(
procedureOrRouter: AnyProcedure | AnyRouter | ProcedureRouterRecord,
): procedureOrRouter is ProcedureRouterRecord {
return !('_def' in procedureOrRouter);
}

const emptyRouter = {
_ctx: null as any,
_errorShape: null as any,
Expand All @@ -165,6 +173,10 @@ const reservedWords = [
* since JS will think that `.then` is something that exists
*/
'then',
/**
* `_def` is a reserved word because it's used internally a lot
*/
'_def',
];

/**
Expand Down Expand Up @@ -200,11 +212,33 @@ export function createRouterFactory<TConfig extends AnyRootConfig>(
);
}

const newProcedures: ProcedureRouterRecord = {};
for (const [key, procedureOrRouter] of Object.entries(procedures ?? {})) {
const value = procedures[key] ?? {};

if (isNestedRouter(value)) {
newProcedures[key] = createRouterInner(value);
continue;
}

if (isRouter(value)) {
newProcedures[key] = procedureOrRouter;
continue;
}

newProcedures[key] = procedureOrRouter;
}

const routerProcedures: ProcedureRecord = omitPrototype({});
function recursiveGetPaths(procedures: ProcedureRouterRecord, path = '') {
for (const [key, procedureOrRouter] of Object.entries(procedures ?? {})) {
const newPath = `${path}${key}`;

if (isNestedRouter(procedureOrRouter)) {
recursiveGetPaths(procedureOrRouter, `${newPath}.`);
continue;
}

if (isRouter(procedureOrRouter)) {
recursiveGetPaths(procedureOrRouter._def.procedures, `${newPath}.`);
continue;
Expand All @@ -217,14 +251,14 @@ export function createRouterFactory<TConfig extends AnyRootConfig>(
routerProcedures[newPath] = procedureOrRouter;
}
}
recursiveGetPaths(procedures);
recursiveGetPaths(newProcedures);

const _def: AnyRouterDef<TConfig> = {
_config: config,
router: true,
procedures: routerProcedures,
...emptyRouter,
record: procedures,
record: newProcedures,
queries: Object.entries(routerProcedures)
.filter((pair) => (pair[1] as any)._def.query)
.reduce((acc, [key, val]) => ({ ...acc, [key]: val }), {}),
Expand All @@ -237,7 +271,7 @@ export function createRouterFactory<TConfig extends AnyRootConfig>(
};

const router: AnyRouter = {
...procedures,
...newProcedures,
_def,
createCaller(ctx) {
const proxy = createRecursiveProxy(({ path, args }) => {
Expand Down Expand Up @@ -295,6 +329,7 @@ export function createRouterFactory<TConfig extends AnyRootConfig>(
return this._def._config.errorFormatter({ ...opts, shape });
},
};

return router as any;
};
}
Expand Down
160 changes: 148 additions & 12 deletions packages/tests/server/router.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import './___packages';
import { ignoreErrors } from './___testHelpers';
import { initTRPC } from '@trpc/server/src/core';
import { z } from 'zod';

const t = initTRPC.create();

describe('router', () => {
test('is a reserved word', async () => {
describe('reserved words', () => {
test('`then` is a reserved word', async () => {
expect(() => {
return t.router({
then: t.procedure.query(() => 'hello'),
Expand All @@ -21,17 +23,151 @@ describe('router', () => {

await asyncFnThatReturnsCaller();
});
});

test('should not duplicate key', async () => {
expect(() =>
t.router({
foo: t.router({
'.bar': t.procedure.query(() => 'bar' as const),
}),
'foo.': t.router({
bar: t.procedure.query(() => 'bar' as const),
}),
test('duplicate keys', async () => {
expect(() =>
t.router({
foo: t.router({
'.bar': t.procedure.query(() => 'bar' as const),
}),
).toThrow('Duplicate key: foo..bar');
'foo.': t.router({
bar: t.procedure.query(() => 'bar' as const),
}),
}),
).toThrow('Duplicate key: foo..bar');
});

describe('shorthand {}', () => {
test('nested sub-router should be accessible', async () => {
const router = t.router({
foo: {
bar: t.procedure.query(() => 'Hello I am recursive'),
},
});

const caller = router.createCaller({});
const result = await caller.foo.bar();
expect(result).toBe('Hello I am recursive');
});

test('multiple nested levels of subrouter should be accessible', async () => {
const router = t.router({
foo: {
bar: {
foo: {
bar: {
foo: {
bar: t.procedure.query(() => 'Hello I am recursive'),
},
},
},
},
},
});

const caller = router.createCaller({});
const result = await caller.foo.bar.foo.bar.foo.bar();
expect(result).toBe('Hello I am recursive');
});

test('multiple nested levels of subrouter with different constructors should be accessible', async () => {
const router = t.router({
foo: {
bar: t.router({
foo: {
bar: {
foo: t.router({
bar: t.procedure.query(() => 'Hello I am recursive'),
}),
},
},
}),
},
});

const caller = router.createCaller({});
const result = await caller.foo.bar.foo.bar.foo.bar();
expect(result).toBe('Hello I am recursive');
});

test('realistic nested router should be accessible', async () => {
const posts = [
{
id: '1',
title: 'Post 1',
},
{
id: '2',
title: 'Post 2',
},
{
id: '3',
title: 'Post 3',
},
];

const router = t.router({
post: {
find: {
all: t.procedure.query(() => posts),
byId: t.procedure
.input(z.object({ id: z.string() }))
.query(({ input }) => {
const post = posts.find((post) => post.id === input.id);
return post;
}),
},
create: {
one: t.procedure
.input(z.object({ title: z.string() }))
.mutation(({ input }) => {
const newPost = {
id: String(posts.length + 1),
title: input.title,
};

posts.push(newPost);

return newPost;
}),
},
},
});

const caller = router.createCaller({});

expect(await caller.post.find.all()).toEqual(posts);
expect(await caller.post.find.byId({ id: '1' })).toEqual(posts[0]);
expect(await caller.post.create.one({ title: 'Post 4' })).toEqual({
id: '4',
title: 'Post 4',
});
});

test('mergeRouters()', () => {
const router1 = t.router({
post: {
foo: t.procedure.query(() => 'bar'),
},
});
const router2 = t.router({
user: {
foo: t.procedure.query(() => 'bar'),
},
});

t.mergeRouters(router1, router2);
});

test('bad values', () => {
ignoreErrors(() => {
t.router({
foo: {
// @ts-expect-error this is a bad value
bar: 'i am wrong',
},
});
});
});
});
33 changes: 33 additions & 0 deletions www/docs/server/merging-routers.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,39 @@ export const userRouter = router({

```

### Defining an inline sub-router

When you define an inline sub-router, you can represent your router as a plain object.

In the below example, `nested1` and `neested2` are equal:

```ts twoslash title="server/_app.ts"
// @filename: trpc.ts
import { initTRPC } from '@trpc/server';
const t = initTRPC.create();


export const middleware = t.middleware;
export const publicProcedure = t.procedure;
export const router = t.router;

// @filename: _app.ts
// ---cut---
import * as trpc from '@trpc/server';
import { publicProcedure, router } from './trpc';

const appRouter = router({
// Shorthand plain object for creating a sub-router
nested1: {
proc: publicProcedure.query(() => '...'),
},
//
nested2: router({
proc : publicProcedure.query(() => '...'),
}),
});
```

## Merging with `t.mergeRouters`

If you prefer having all procedures flat in one single namespace, you can instead use `t.mergeRouters`
Expand Down

3 comments on commit 9c6ff8f

@vercel
Copy link

@vercel vercel bot commented on 9c6ff8f Mar 10, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

www – ./www

www-trpc.vercel.app
www-git-main-trpc.vercel.app
www.trpc.io
beta.trpc.io
trpc.io
alpha.trpc.io

@vercel
Copy link

@vercel vercel bot commented on 9c6ff8f Mar 10, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

next-prisma-starter – ./examples/next-prisma-starter

nextjs.trpc.io
next-prisma-starter-git-main-trpc.vercel.app
next-prisma-starter-trpc.vercel.app

@vercel
Copy link

@vercel vercel bot commented on 9c6ff8f Mar 10, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

og-image – ./www/og-image

og-image-git-main-trpc.vercel.app
og-image.trpc.io
og-image-three-neon.vercel.app
og-image-trpc.vercel.app

Please sign in to comment.