Skip to content

Commit

Permalink
feat: add nested router support
Browse files Browse the repository at this point in the history
  • Loading branch information
malo committed Jan 30, 2023
1 parent 992f2d4 commit 8c03b41
Show file tree
Hide file tree
Showing 6 changed files with 183 additions and 60 deletions.
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

<div align="center">
<img src="assets/trpc-msw.png" style="height: 100px;"/>
<h1>msw-trpc</h1>
Expand Down Expand Up @@ -60,6 +59,25 @@ trpcMsw.myQuery.query((req, res, ctx) => {})
trpcMsw.myMutation.mutation((req, res, ctx) => {})
```

**supports merged routers**

```typescript
// @filename: routers/_app.ts
// taken from https://trpc.io/docs/merging-routers
import { userRouter } from './user'
import { postRouter } from './post'

const appRouter = router({
user: userRouter, // put procedures under "user" namespace
post: postRouter, // put procedures under "post" namespace
})

// @filename: frontend/test/PostList.tsx

// all nested routers will be infered properly
trpcMsw.user.list.query((req, res, ctx) => {})
```

## MSW Augments

**ctx.data**
Expand Down Expand Up @@ -149,3 +167,8 @@ Peer dependencies:
Please note:
- Batch is not yet supported
- Merged routers will match in MSW against . or / (I'm open to PRs on how to only match against . 😊 )
```typescript
mswTrpc.user.list.query() // this will match /trpc/user/list and /trpc/user.list
```
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "msw-trpc",
"version": "1.0.3",
"version": "1.1.0",
"description": "Trpc API for Mock Service Worker (MSW).",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down
135 changes: 79 additions & 56 deletions src/createTRPCMsw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,102 +18,125 @@ const getQueryInput = (req: RestRequest) => {
return JSON.parse(inputString)
}

const createTRPCMsw = <Router extends AnyRouter>({
baseUrl,
basePath = 'trpc',
transformer = defaultTransformer,
}: { baseUrl?: string; basePath?: string; transformer?: CombinedDataTransformer } = {}) => {
type ExtractKeys<T extends Router, K extends keyof T = keyof T> = T[K] extends
const getRegexpAsString = (baseUrl: string | RegExp) => {
if (baseUrl instanceof RegExp === false) return baseUrl

let baseUrlAsString = `${baseUrl}`.replace('\\/', '')
if (baseUrlAsString[0] === '/') baseUrlAsString = baseUrlAsString.substring(1)
if (baseUrlAsString[baseUrlAsString.length - 1] === '/')
baseUrlAsString = baseUrlAsString.substring(0, baseUrlAsString.length - 1)
return baseUrlAsString
}

const buildUrlFromPathParts = (pathParts: string[]) => new RegExp(pathParts.map(getRegexpAsString).join('[/.|.]'))

// @ts-expect-error any
const createUntypedTRPCMsw = (
{
baseUrl,
basePath = 'trpc',
transformer = defaultTransformer,
}: { baseUrl?: string; basePath?: string; transformer?: CombinedDataTransformer } = {},
pathParts: string[] = []
) => {
return new Proxy(
{},
{
get(_target: unknown, procedureKey) {
if (procedureKey === 'query') {
// @ts-expect-error any
return handler =>
rest.get(buildUrlFromPathParts(pathParts), (req, res, ctx) => {
return handler({ ...req, getInput: () => getQueryInput(req) }, res, {
...ctx,
// @ts-expect-error any
data: body => ctx.json({ result: { data: transformer.input.serialize(body) } }),
})
})
}

if (procedureKey === 'mutation') {
// @ts-expect-error any
return handler =>
rest.post(buildUrlFromPathParts(pathParts), (req, res, ctx) => {
return handler(req, res, {
...ctx,
// @ts-expect-error any
data: body => ctx.json({ result: { data: transformer.input.serialize(body) } }),
})
})
}

const newPathParts =
pathParts.length === 0 ? (baseUrl != null ? [baseUrl] : [`\/${basePath}` as string]) : pathParts

return createUntypedTRPCMsw({ transformer }, [...newPathParts, procedureKey as string])
},
}
)
}

const createTRPCMsw = <Router extends AnyRouter>(
config: { baseUrl?: string; basePath?: string; transformer?: CombinedDataTransformer } = {}
) => {
type ExtractKeys<T extends Router[any], K extends keyof T = keyof T> = T[K] extends
| BuildProcedure<'query', any, any>
| BuildProcedure<'mutation', any, any>
| Router[any]
? K
: never

type ExtractInput<T extends ProcedureParams> = T extends ProcedureParams<any, any, any, infer P> ? P : never

type WithInput<T extends Router, K extends keyof T = keyof T> = {
type WithInput<T extends Router[any], K extends keyof T = keyof T> = {
getInput: () => T[K] extends BuildProcedure<any, infer P, any> ? ExtractInput<P> : never
}

type ContextWithDataTransformer<T extends Router, K extends keyof T = keyof T> = RestContext & {
type ContextWithDataTransformer<T extends Router[any], K extends keyof T = keyof T> = RestContext & {
data: (data: T[K] extends BuildProcedure<any, any, infer P> ? P : never) => any
}

type SetQueryHandler<T extends Router, K extends keyof T> = (
type SetQueryHandler<T extends Router[any], K extends keyof T> = (
handler: ResponseResolver<
RestRequest<never, PathParams<string>> & WithInput<T, K>,
ContextWithDataTransformer<T, K>,
DefaultBodyType
>
) => RestHandler<MockedRequest<DefaultBodyType>>

type SetMutationHandler<T extends Router, K extends keyof T> = (
type SetMutationHandler<T extends Router[any], K extends keyof T> = (
handler: ResponseResolver<
//@ts-expect-error DefaultBodyType doesn't handle unknown but it will be resolved at usage time
RestRequest<T[K] extends BuildProcedure<any, infer P, any> ? ExtractInput<P> : DefaultBodyType, PathParams>,
ContextWithDataTransformer<T, K>
>
) => RestHandler<MockedRequest<DefaultBodyType>>

type Query<T extends Router, K extends keyof T> = {
type Query<T extends Router[any], K extends keyof T> = {
query: SetQueryHandler<T, K>
}

type Mutation<T extends Router, K extends keyof T> = {
type Mutation<T extends Router[any], K extends keyof T> = {
mutation: SetMutationHandler<T, K>
}

type QueryAndMutation<T extends Router, K extends keyof T> = Query<T, K> & Mutation<T, K>

type ExtractProcedureHandler<T extends Router, K extends keyof T> = T[K] extends BuildProcedure<'mutation', any, any>
type ExtractProcedureHandler<T extends Router | Router[any], K extends keyof T> = T[K] extends BuildProcedure<
'mutation',
any,
any
>
? Mutation<T, K>
: T[K] extends BuildProcedure<'query', any, any>
? Query<T, K>
: T[K] extends Router[any]
? MswTrpc<T[K]>
: never

type ExtractProcedures = {
[key in keyof Router as ExtractKeys<Router, key>]: ExtractProcedureHandler<Router, key>
type MswTrpc<T extends Router | Router[any]> = {
[key in keyof T as ExtractKeys<T, key>]: ExtractProcedureHandler<T, key>
}

const isProceduresKey = (key: keyof ExtractProcedures | string | symbol): key is keyof ExtractProcedures => {
if (typeof key !== 'symbol') return true
return false
}

return new Proxy(
{},
{
get(_target: unknown, procedureKey: keyof ExtractProcedures | string | symbol) {
if (!isProceduresKey(procedureKey)) return

const procedure = {} as QueryAndMutation<Router, keyof Router>

const path = baseUrl
? `${baseUrl}/${procedureKey as string}`
: new RegExp(`\/${basePath}\/${procedureKey as string}`)

procedure.query = handler =>
rest.get(path, (req, res, ctx) => {
// @ts-expect-error any
return handler({ ...req, getInput: () => getQueryInput(req) }, res, {
...ctx,
data: body => ctx.json({ result: { data: transformer.input.serialize(body) } }),
})
})

procedure.mutation = handler =>
rest.post(path, (req, res, ctx) => {
// @ts-expect-error any
return handler(req, res, {
...ctx,
data: body => ctx.json({ result: { data: transformer.input.serialize(body) } }),
})
})

return procedure as ExtractProcedures[typeof procedureKey]
},
}
) as ExtractProcedures
return createUntypedTRPCMsw(config) as MswTrpc<Router>
}

export default createTRPCMsw
18 changes: 17 additions & 1 deletion test/createTRPCMsw.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
MockedRequest,
RestContext,
} from 'msw'
import { mswTrpc, User } from './setup'
import { mswTrpc, nestedMswTrpc, User } from './setup'

describe('proxy returned by createMswTrpc', () => {
it('should expose property query on properties that match TRPC query procedures', () => {
Expand Down Expand Up @@ -37,4 +37,20 @@ describe('proxy returned by createMswTrpc', () => {
) => RestHandler<MockedRequest<DefaultBodyType>>
>()
})

describe('with merged routers', () => {
it('should expose property query on properties that match TRPC query procedures', () => {
expectTypeOf(nestedMswTrpc.users.userById.query).toEqualTypeOf<
(
handler: ResponseResolver<
RestRequest<never, PathParams<string>> & { getInput: () => string },
RestContext & {
data: (data: User | undefined) => any
},
DefaultBodyType
>
) => RestHandler<MockedRequest<DefaultBodyType>>
>()
})
})
})
43 changes: 42 additions & 1 deletion test/integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AppRouter, mswTrpc, trpc } from './setup'
import { AppRouter, mswTrpc, NestedAppRouter, nestedMswTrpc, nestedTrpc, trpc } from './setup'

import { setupServer } from 'msw/node'
import { createTRPCMsw } from '../src'
Expand All @@ -10,6 +10,12 @@ describe('queries and mutations', () => {
}),
mswTrpc.createUser.mutation(async (req, res, ctx) => {
return res(ctx.status(200), ctx.data({ id: '2', name: await req.json() }))
}),
nestedMswTrpc.users.userById.query((req, res, ctx) => {
return res(ctx.status(200), ctx.data({ id: '1', name: 'Malo' }))
}),
nestedMswTrpc.users.createUser.mutation(async (req, res, ctx) => {
return res(ctx.status(200), ctx.data({ id: '2', name: await req.json() }))
})
)

Expand All @@ -28,18 +34,39 @@ describe('queries and mutations', () => {

expect(user).toEqual({ id: '2', name: 'Robert' })
})

describe('nested router', () => {
test('msw server setup from msw-trpc query handle should handle queries properly', async () => {
const user = await nestedTrpc.users.userById.query('1')

expect(user).toEqual({ id: '1', name: 'Malo' })
})

test('msw server setup from msw-trpc query handle should handle mutations properly', async () => {
const user = await nestedTrpc.users.createUser.mutate('Robert')

expect(user).toEqual({ id: '2', name: 'Robert' })
})
})
})

describe('config', () => {
describe('createTRCPMsw should map requests to baseUrl prop when passed', () => {
const mswTrpc = createTRPCMsw<AppRouter>({ baseUrl: 'http://localhost:3000/trpc' })
const nestedMswTrpc = createTRPCMsw<NestedAppRouter>({ baseUrl: 'http://localhost:3000/trpc' })

const server = setupServer(
mswTrpc.userById.query((req, res, ctx) => {
return res(ctx.status(200), ctx.data({ id: '1', name: 'Malo' }))
}),
mswTrpc.createUser.mutation(async (req, res, ctx) => {
return res(ctx.status(200), ctx.data({ id: '2', name: await req.json() }))
}),
nestedMswTrpc.users.userById.query((req, res, ctx) => {
return res(ctx.status(200), ctx.data({ id: '1', name: 'Malo' }))
}),
nestedMswTrpc.users.createUser.mutation(async (req, res, ctx) => {
return res(ctx.status(200), ctx.data({ id: '2', name: await req.json() }))
})
)

Expand All @@ -58,5 +85,19 @@ describe('config', () => {

expect(user).toEqual({ id: '2', name: 'Robert' })
})

describe('nested router', () => {
test('msw server setup from msw-trpc query handle should handle queries properly', async () => {
const user = await nestedTrpc.users.userById.query('1')

expect(user).toEqual({ id: '1', name: 'Malo' })
})

test('msw server setup from msw-trpc query handle should handle mutations properly', async () => {
const user = await nestedTrpc.users.createUser.mutate('Robert')

expect(user).toEqual({ id: '2', name: 'Robert' })
})
})
})
})
20 changes: 20 additions & 0 deletions test/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ const appRouter = t.router({
})
export type AppRouter = typeof appRouter

const nestedRouter = t.router({
users: appRouter,
})

export type NestedAppRouter = typeof nestedRouter

export const trpc = createTRPCProxyClient<AppRouter>({
links: [
httpLink({
Expand All @@ -58,4 +64,18 @@ export const trpc = createTRPCProxyClient<AppRouter>({
],
})

export const nestedTrpc = createTRPCProxyClient<NestedAppRouter>({
links: [
httpLink({
url: 'http://localhost:3000/trpc',
headers() {
return {
'content-type': 'application/json',
}
},
}),
],
})

export const mswTrpc = createTRPCMsw<AppRouter>()
export const nestedMswTrpc = createTRPCMsw<NestedAppRouter>()

0 comments on commit 8c03b41

Please sign in to comment.