Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
1a2110f
feat(http): support custom predicate functions in http request handlers
ytoshiki Jun 30, 2025
ba63e46
feat(graphql): support custom predicate functions in graphql request …
ytoshiki Jul 1, 2025
d0253f8
fix: change path type
ytoshiki Jul 1, 2025
b01417f
test: node
ytoshiki Jul 1, 2025
570692b
test: browser
ytoshiki Jul 1, 2025
157b54c
chore: change import
ytoshiki Jul 1, 2025
dc0efc7
chore: delete tests
ytoshiki Jul 1, 2025
c5b9eb2
chore: fix path
ytoshiki Jul 1, 2025
3388f96
chore: fix path resolving error
ytoshiki Jul 1, 2025
6f8d5fa
Merge branch 'main' into feature/custom-predicate-function
ytoshiki Jul 1, 2025
d820b6e
chore: change path
ytoshiki Jul 1, 2025
5763c33
fix: test
ytoshiki Jul 1, 2025
1e9a8e3
fix(http): rename path to predicate
ytoshiki Jul 5, 2025
8b00de3
fix(graphql): rename operationName to predicate
ytoshiki Jul 5, 2025
da1f44c
feat: add predicate caching to RequestHandler for performance optimiz…
ytoshiki Jul 5, 2025
06f01e9
fix: predicate cache logic
ytoshiki Jul 5, 2025
4452a33
chore: use consistent typeof checks
ytoshiki Jul 5, 2025
dc60641
Revert "chore: use consistent typeof checks"
ytoshiki Jul 6, 2025
9ffca63
Revert "fix: predicate cache logic"
ytoshiki Jul 6, 2025
b37a5e0
Revert "feat: add predicate caching to RequestHandler for performance…
ytoshiki Jul 6, 2025
fd5cd0f
Merge branch 'main' into feature/custom-predicate-function
ytoshiki Jul 11, 2025
c3f89aa
Merge branch 'main' into feature/custom-predicate-function
kettanaito Aug 28, 2025
7dd1046
fix: polish handler classes
kettanaito Aug 28, 2025
54f0481
fix(GraphQLHandler): support `{ matches }` predicate return type
kettanaito Aug 28, 2025
98ff915
fix(core): export `GraphQLOperationType` type
kettanaito Aug 28, 2025
61d5a99
fix(HttpHandler): path params inference
kettanaito Aug 28, 2025
7cb1fb6
test: add type tests for custom predicate
kettanaito Aug 28, 2025
4baba5c
chore: polish handler tests
kettanaito Aug 29, 2025
18dfcc9
test: add unit tests for custom predicate function
kettanaito Aug 29, 2025
8ea8e27
test: polish node custom predicate tests
kettanaito Aug 29, 2025
4765f98
test: polish graphql custom predicate tests
kettanaito Aug 29, 2025
af5b4d6
test: polish e2e custom predicate tests
kettanaito Aug 29, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/core/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
GraphQLResolverExtras,
GraphQLResponseBody,
GraphQLQuery,
GraphQLCustomPredicate,
} from './handlers/GraphQLHandler'
import type { Path } from './utils/matching/matchRequestUrl'

Expand All @@ -30,7 +31,8 @@ export type GraphQLRequestHandler = <
operationName:
| GraphQLHandlerNameSelector
| DocumentNode
| TypedDocumentNode<Query, Variables>,
| TypedDocumentNode<Query, Variables>
| GraphQLCustomPredicate<GraphQLVariables>,
resolver: GraphQLResponseResolver<
[Query] extends [never] ? GraphQLQuery : Query,
Variables
Expand All @@ -52,6 +54,12 @@ function createScopedGraphQLHandler(
url: Path,
): GraphQLRequestHandler {
return (operationName, resolver, options = {}) => {
if (typeof operationName === 'function') {
return new GraphQLHandler(operationType, '', url, resolver, {
...options,
predicate: operationName,
})
}
return new GraphQLHandler(
operationType,
operationName,
Expand Down
14 changes: 7 additions & 7 deletions src/core/handlers/GraphQLHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -557,13 +557,13 @@ describe('predicate', () => {
})

expect(
handler.predicate({
await handler.predicate({
request,
parsedResult: await handler.parse({ request }),
}),
).toBe(true)
expect(
handler.predicate({
await handler.predicate({
request: alienRequest,
parsedResult: await handler.parse({ request: alienRequest }),
}),
Expand Down Expand Up @@ -591,13 +591,13 @@ describe('predicate', () => {
})

expect(
handler.predicate({
await handler.predicate({
request,
parsedResult: await handler.parse({ request }),
}),
).toBe(true)
expect(
handler.predicate({
await handler.predicate({
request: alienRequest,
parsedResult: await handler.parse({ request: alienRequest }),
}),
Expand All @@ -618,7 +618,7 @@ describe('predicate', () => {
})

expect(
handler.predicate({
await handler.predicate({
request,
parsedResult: await handler.parse({ request }),
}),
Expand All @@ -643,13 +643,13 @@ describe('predicate', () => {
})

expect(
handler.predicate({
await handler.predicate({
request,
parsedResult: await handler.parse({ request }),
}),
).toBe(true)
expect(
handler.predicate({
await handler.predicate({
request: alienRequest,
parsedResult: await handler.parse({ request: alienRequest }),
}),
Expand Down
37 changes: 29 additions & 8 deletions src/core/handlers/GraphQLHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,14 @@ export type GraphQLResponseBody<BodyType extends DefaultBodyType> =
| null
| undefined

export type GraphQLCustomPredicate<GraphQLVariables> = (args: {
request: Request
query: string
operationName: string
variables: GraphQLVariables
cookies: Record<string, string>
}) => boolean | Promise<boolean>

export function isDocumentNode(
value: DocumentNode | any,
): value is DocumentNode {
Expand All @@ -93,6 +101,7 @@ export class GraphQLHandler extends RequestHandler<
GraphQLResolverExtras<any>
> {
private endpoint: Path
private customPredicate?: GraphQLCustomPredicate<GraphQLVariables>

static parsedRequestCache = new WeakMap<
Request,
Expand All @@ -104,7 +113,9 @@ export class GraphQLHandler extends RequestHandler<
operationName: GraphQLHandlerNameSelector,
endpoint: Path,
resolver: ResponseResolver<GraphQLResolverExtras<any>, any, any>,
options?: RequestHandlerOptions,
options?: RequestHandlerOptions & {
predicate?: GraphQLCustomPredicate<GraphQLVariables>
},
) {
let resolvedOperationName = operationName

Expand Down Expand Up @@ -141,6 +152,7 @@ export class GraphQLHandler extends RequestHandler<
options,
})

this.customPredicate = options?.predicate
this.endpoint = endpoint
}

Expand Down Expand Up @@ -196,33 +208,42 @@ export class GraphQLHandler extends RequestHandler<
}
}

predicate(args: {
async predicate(args: {
request: Request
parsedResult: GraphQLRequestParsedResult
}) {
}): Promise<boolean> {
if (this.customPredicate) {
// Clone the request to avoid locking its body stream.
// The body can only be read once, so cloning allows both
// the predicate and resolver to access it independently.
const clonedRequest = args.request.clone()

return await this.customPredicate({
request: clonedRequest,
...this.extendResolverArgs({
request: clonedRequest,
parsedResult: args.parsedResult,
}),
})
}
if (args.parsedResult.operationType === undefined) {
return false
}

if (!args.parsedResult.operationName && this.info.operationType !== 'all') {
const publicUrl = toPublicUrl(args.request.url)

devUtils.warn(`\
Failed to intercept a GraphQL request at "${args.request.method} ${publicUrl}": anonymous GraphQL operations are not supported.

Consider naming this operation or using "graphql.operation()" request handler to intercept GraphQL requests regardless of their operation name/type. Read more: https://mswjs.io/docs/api/graphql/#graphqloperationresolver`)
return false
}

const hasMatchingOperationType =
this.info.operationType === 'all' ||
args.parsedResult.operationType === this.info.operationType

const hasMatchingOperationName =
this.info.operationName instanceof RegExp
? this.info.operationName.test(args.parsedResult.operationName || '')
: args.parsedResult.operationName === this.info.operationName

return (
args.parsedResult.match.matches &&
hasMatchingOperationType &&
Expand Down
6 changes: 3 additions & 3 deletions src/core/handlers/HttpHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ describe('predicate', () => {
})

expect(
handler.predicate({
await handler.predicate({
request,
parsedResult: await handler.parse({ request }),
}),
Expand All @@ -95,7 +95,7 @@ describe('predicate', () => {

for (const request of requests) {
expect(
handler.predicate({
await handler.predicate({
request,
parsedResult: await handler.parse({ request }),
}),
Expand All @@ -108,7 +108,7 @@ describe('predicate', () => {
const request = new Request(new URL('/user/abc-123', location.href))

expect(
handler.predicate({
await handler.predicate({
request,
parsedResult: await handler.parse({ request }),
}),
Expand Down
42 changes: 33 additions & 9 deletions src/core/handlers/HttpHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ export type HttpRequestResolverExtras<Params extends PathParams> = {
cookies: Record<string, string>
}

export type HttpCustomPredicate = (args: {
request: Request
cookies: Record<string, string>
}) => boolean | Promise<boolean>
Copy link
Contributor Author

@ytoshiki ytoshiki Jul 1, 2025

Choose a reason for hiding this comment

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

params is always an empty object when using a custom predicate because path is a function, so users cannot actually use it for any matching logic.

Copy link
Member

Choose a reason for hiding this comment

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

A good point. I wonder if we can do something to help with this. I think exporting a function that parses any request URL against any path would be nice. That way, if the developer is using a custom predicate function, they can still parse out the request url into params.

We have matchRequestUrl() exported from msw to do precisely that!

Copy link
Member

Choose a reason for hiding this comment

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

I am making the custom predicate return type a union of boolean or { match: boolean, params: PathParams } so the user can provide an extended predicate if they still wish to preserve path parameter parsing.

They should be able to do that using the matchRequestUrl function from MSW:

import { matchRequestUrl } from 'msw'

http.get(({ request }) => {
  return {
    matches: myCustomLogic,
    params: matchRequestUrl(request.url, '/:foo').params,
  }
})


export type HttpRequestPath = Path | HttpCustomPredicate

/**
* Request handler for HTTP requests.
* Provides request matching based on method and URL.
Expand All @@ -61,11 +68,15 @@ export class HttpHandler extends RequestHandler<
HttpRequestParsedResult,
HttpRequestResolverExtras<any>
> {
private customPredicate?: HttpCustomPredicate

constructor(
method: HttpHandlerMethod,
path: Path,
resolver: ResponseResolver<HttpRequestResolverExtras<any>, any, any>,
options?: RequestHandlerOptions,
options?: RequestHandlerOptions & {
predicate?: HttpCustomPredicate
},
) {
super({
info: {
Expand All @@ -76,14 +87,14 @@ export class HttpHandler extends RequestHandler<
resolver,
options,
})

this.customPredicate = options?.predicate
this.checkRedundantQueryParameters()
}

private checkRedundantQueryParameters() {
const { method, path } = this.info

if (path instanceof RegExp) {
if (!path || path instanceof RegExp) {
return
}

Expand Down Expand Up @@ -111,11 +122,9 @@ export class HttpHandler extends RequestHandler<
resolutionContext?: ResponseResolutionContext
}) {
const url = new URL(args.request.url)
const match = matchRequestUrl(
url,
this.info.path,
args.resolutionContext?.baseUrl,
)
const match = this.info.path
? matchRequestUrl(url, this.info.path, args.resolutionContext?.baseUrl)
: { matches: false, params: {} }
const cookies = getAllRequestCookies(args.request)

return {
Expand All @@ -124,7 +133,22 @@ export class HttpHandler extends RequestHandler<
}
}

predicate(args: { request: Request; parsedResult: HttpRequestParsedResult }) {
async predicate(args: {
request: Request
parsedResult: HttpRequestParsedResult
resolutionContext?: ResponseResolutionContext
}) {
if (this.customPredicate) {
// Clone the request to avoid locking its body stream.
// The body can only be read once, so cloning allows both
// the predicate and resolver to access it independently.
const clonedRequest = args.request.clone()

return await this.customPredicate({
request: clonedRequest,
cookies: args.parsedResult.cookies,
})
}
const hasMatchingMethod = this.matchMethod(args.request.method)
const hasMatchingUrl = args.parsedResult.match.matches
return hasMatchingMethod && hasMatchingUrl
Expand Down
4 changes: 2 additions & 2 deletions src/core/handlers/RequestHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ export abstract class RequestHandler<
request: Request
parsedResult: ParsedResult
resolutionContext?: ResponseResolutionContext
}): boolean
}): boolean | Promise<boolean>

/**
* Print out the successfully handled request.
Expand Down Expand Up @@ -273,7 +273,7 @@ export abstract class RequestHandler<
request: args.request,
resolutionContext: args.resolutionContext,
})
const shouldInterceptRequest = this.predicate({
const shouldInterceptRequest = await this.predicate({
request: args.request,
parsedResult,
resolutionContext: args.resolutionContext,
Expand Down
11 changes: 9 additions & 2 deletions src/core/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ import {
HttpMethods,
HttpHandler,
HttpRequestResolverExtras,
HttpRequestPath,
} from './handlers/HttpHandler'
import type { Path, PathParams } from './utils/matching/matchRequestUrl'
import type { PathParams } from './utils/matching/matchRequestUrl'

export type HttpRequestHandler = <
Params extends PathParams<keyof Params> = PathParams,
Expand All @@ -18,7 +19,7 @@ export type HttpRequestHandler = <
// returns plain "Response" and the one returning "HttpResponse"
// to enforce a stricter response body type.
ResponseBodyType extends DefaultBodyType = undefined,
RequestPath extends Path = Path,
RequestPath extends HttpRequestPath = HttpRequestPath,
>(
path: RequestPath,
resolver: HttpResponseResolver<Params, RequestBodyType, ResponseBodyType>,
Expand All @@ -39,6 +40,12 @@ function createHttpHandler<Method extends HttpMethods | RegExp>(
method: Method,
): HttpRequestHandler {
return (path, resolver, options = {}) => {
if (typeof path === 'function') {
Copy link
Member

Choose a reason for hiding this comment

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

What do you think if we handle this branching in the HttpHandler (if not RequestHandler) instead?

  1. Rename path argument to predicate.
  2. Handle it being a function in the .predicate() method.

This way, these namespace functions don't have to become more complex.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I completely agree. That is a much cleaner approach.

return new HttpHandler(method, '', resolver, {
...options,
predicate: path,
})
}
return new HttpHandler(method, path, resolver, options)
}
}
Expand Down
2 changes: 2 additions & 0 deletions src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export type {
export type {
RequestQuery,
HttpRequestParsedResult,
HttpCustomPredicate,
} from './handlers/HttpHandler'
export type { HttpRequestHandler, HttpResponseResolver } from './http'

Expand All @@ -50,6 +51,7 @@ export type {
GraphQLVariables,
GraphQLRequestBody,
GraphQLJsonRequestBody,
GraphQLCustomPredicate,
} from './handlers/GraphQLHandler'
export type { GraphQLRequestHandler, GraphQLResponseResolver } from './graphql'

Expand Down
Loading