diff --git a/src/core/graphql.ts b/src/core/graphql.ts index 49276359a..afd828529 100644 --- a/src/core/graphql.ts +++ b/src/core/graphql.ts @@ -6,11 +6,12 @@ import { import { GraphQLHandler, GraphQLVariables, - ExpectedOperationTypeNode, + GraphQLOperationType, GraphQLHandlerNameSelector, GraphQLResolverExtras, GraphQLResponseBody, GraphQLQuery, + GraphQLCustomPredicate, } from './handlers/GraphQLHandler' import type { Path } from './utils/matching/matchRequestUrl' @@ -27,10 +28,11 @@ export type GraphQLRequestHandler = < Query extends GraphQLQuery = GraphQLQuery, Variables extends GraphQLVariables = GraphQLVariables, >( - operationName: + predicate: | GraphQLHandlerNameSelector | DocumentNode - | TypedDocumentNode, + | TypedDocumentNode + | GraphQLCustomPredicate, resolver: GraphQLResponseResolver< [Query] extends [never] ? GraphQLQuery : Query, Variables @@ -48,17 +50,11 @@ export type GraphQLResponseResolver< > function createScopedGraphQLHandler( - operationType: ExpectedOperationTypeNode, + operationType: GraphQLOperationType, url: Path, ): GraphQLRequestHandler { - return (operationName, resolver, options = {}) => { - return new GraphQLHandler( - operationType, - operationName, - url, - resolver, - options, - ) + return (predicate, resolver, options = {}) => { + return new GraphQLHandler(operationType, predicate, url, resolver, options) } } diff --git a/src/core/handlers/GraphQLHandler.test.ts b/src/core/handlers/GraphQLHandler.test.ts index f5d2dc3f8..e2d5bda81 100644 --- a/src/core/handlers/GraphQLHandler.test.ts +++ b/src/core/handlers/GraphQLHandler.test.ts @@ -1,6 +1,4 @@ -/** - * @vitest-environment jsdom - */ +// @vitest-environment jsdom import { createRequestId, encodeBuffer } from '@mswjs/interceptors' import { OperationTypeNode, parse } from 'graphql' import { @@ -62,7 +60,7 @@ const LOGIN = ` ` describe('info', () => { - test('exposes request handler information for query', () => { + it('exposes request handler information for query', () => { const handler = new GraphQLHandler( OperationTypeNode.QUERY, 'GetUser', @@ -75,7 +73,7 @@ describe('info', () => { expect(handler.info.operationName).toEqual('GetUser') }) - test('exposes request handler information for mutation', () => { + it('exposes request handler information for mutation', () => { const handler = new GraphQLHandler( OperationTypeNode.MUTATION, 'Login', @@ -88,7 +86,7 @@ describe('info', () => { expect(handler.info.operationName).toEqual('Login') }) - test('parses a query operation name from a given DocumentNode', () => { + it('parses a query operation name from a given DocumentNode', () => { const node = parse(` query GetUser { user { @@ -109,7 +107,7 @@ describe('info', () => { expect(handler.info).toHaveProperty('operationName', 'GetUser') }) - test('parses a mutation operation name from a given DocumentNode', () => { + it('parses a mutation operation name from a given DocumentNode', () => { const node = parse(` mutation Login { user { @@ -129,7 +127,7 @@ describe('info', () => { expect(handler.info).toHaveProperty('operationName', 'Login') }) - test('throws an exception given a DocumentNode with a mismatched operation type', () => { + it('throws an exception given a DocumentNode with a mismatched operation type', () => { const node = parse(` mutation CreateUser { user { @@ -148,7 +146,7 @@ describe('info', () => { describe('parse', () => { describe('query', () => { - test('parses a query without variables (GET)', async () => { + it('parses a query without variables (GET)', async () => { const handler = new GraphQLHandler( OperationTypeNode.QUERY, 'GetUser', @@ -174,7 +172,7 @@ describe('parse', () => { }) }) - test('parses a query with variables (GET)', async () => { + it('parses a query with variables (GET)', async () => { const handler = new GraphQLHandler( OperationTypeNode.QUERY, 'GetUser', @@ -205,7 +203,7 @@ describe('parse', () => { }) }) - test('parses a query without variables (POST)', async () => { + it('parses a query without variables (POST)', async () => { const handler = new GraphQLHandler( OperationTypeNode.QUERY, 'GetUser', @@ -231,7 +229,7 @@ describe('parse', () => { }) }) - test('parses a query with variables (POST)', async () => { + it('parses a query with variables (POST)', async () => { const handler = new GraphQLHandler( OperationTypeNode.QUERY, 'GetUser', @@ -264,7 +262,7 @@ describe('parse', () => { }) describe('mutation', () => { - test('parses a mutation without variables (GET)', async () => { + it('parses a mutation without variables (GET)', async () => { const handler = new GraphQLHandler( OperationTypeNode.MUTATION, 'GetUser', @@ -290,7 +288,7 @@ describe('parse', () => { }) }) - test('parses a mutation with variables (GET)', async () => { + it('parses a mutation with variables (GET)', async () => { const handler = new GraphQLHandler( OperationTypeNode.MUTATION, 'GetUser', @@ -321,7 +319,7 @@ describe('parse', () => { }) }) - test('parses a mutation without variables (POST)', async () => { + it('parses a mutation without variables (POST)', async () => { const handler = new GraphQLHandler( OperationTypeNode.MUTATION, 'GetUser', @@ -347,7 +345,7 @@ describe('parse', () => { }) }) - test('parses a mutation with variables (POST)', async () => { + it('parses a mutation with variables (POST)', async () => { const handler = new GraphQLHandler( OperationTypeNode.MUTATION, 'GetUser', @@ -380,7 +378,7 @@ describe('parse', () => { }) describe('with endpoint configuration', () => { - test('parses the request and parses grapqhl properties from it when the graphql.link endpoint matches', async () => { + it('parses the request and parses grapqhl properties from it when the graphql.link endpoint matches', async () => { const handler = new GraphQLHandler( OperationTypeNode.QUERY, 'GetUser', @@ -441,7 +439,7 @@ describe('parse', () => { }) }) - test('parses a request but does not parse graphql properties from it graphql.link hostname does not match', async () => { + it('parses a request but does not parse graphql properties from it graphql.link hostname does not match', async () => { const handler = new GraphQLHandler( OperationTypeNode.QUERY, 'GetUser', @@ -490,7 +488,7 @@ describe('parse', () => { }) }) - test('parses a request but does not parse graphql properties from it graphql.link pathname does not match', async () => { + it('parses a request but does not parse graphql properties from it graphql.link pathname does not match', async () => { const handler = new GraphQLHandler( OperationTypeNode.QUERY, 'GetUser', @@ -542,7 +540,7 @@ describe('parse', () => { }) describe('predicate', () => { - test('respects operation type', async () => { + it('respects operation type', async () => { const handler = new GraphQLHandler( OperationTypeNode.QUERY, 'GetUser', @@ -557,20 +555,20 @@ 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 }), }), ).toBe(false) }) - test('respects operation name', async () => { + it('respects operation name', async () => { const handler = new GraphQLHandler( OperationTypeNode.QUERY, 'GetUser', @@ -590,21 +588,21 @@ describe('predicate', () => { `, }) - expect( + await expect( handler.predicate({ request, parsedResult: await handler.parse({ request }), }), - ).toBe(true) - expect( + ).resolves.toBe(true) + await expect( handler.predicate({ request: alienRequest, parsedResult: await handler.parse({ request: alienRequest }), }), - ).toBe(false) + ).resolves.toBe(false) }) - test('allows anonymous GraphQL opertaions when using "all" expected operation type', async () => { + it('allows anonymous GraphQL opertaions when using "all" expected operation type', async () => { const handler = new GraphQLHandler('all', new RegExp('.*'), '*', resolver) const request = createPostGraphQLRequest({ query: ` @@ -617,15 +615,15 @@ describe('predicate', () => { `, }) - expect( + await expect( handler.predicate({ request, parsedResult: await handler.parse({ request }), }), - ).toBe(true) + ).resolves.toBe(true) }) - test('respects custom endpoint', async () => { + it('respects custom endpoint', async () => { const handler = new GraphQLHandler( OperationTypeNode.QUERY, 'GetUser', @@ -642,23 +640,60 @@ describe('predicate', () => { query: GET_USER, }) - expect( + await expect( handler.predicate({ request, parsedResult: await handler.parse({ request }), }), - ).toBe(true) - expect( + ).resolves.toBe(true) + await expect( handler.predicate({ request: alienRequest, parsedResult: await handler.parse({ request: alienRequest }), }), - ).toBe(false) + ).resolves.toBe(false) + }) + + it('supports custom predicate function', async () => { + const handler = new GraphQLHandler( + OperationTypeNode.QUERY, + ({ query }) => { + return query.includes('password') + }, + /.+/, + resolver, + ) + + { + const request = createPostGraphQLRequest({ + query: `query GetUser { user { password } }`, + }) + + await expect( + handler.predicate({ + request, + parsedResult: await handler.parse({ request }), + }), + ).resolves.toBe(true) + } + + { + const request = createPostGraphQLRequest({ + query: `query GetUser { user { nonMatching } }`, + }) + + await expect( + handler.predicate({ + request, + parsedResult: await handler.parse({ request }), + }), + ).resolves.toBe(false) + } }) }) describe('test', () => { - test('respects operation type', async () => { + it('respects operation type', async () => { const handler = new GraphQLHandler( OperationTypeNode.QUERY, 'GetUser', @@ -676,7 +711,7 @@ describe('test', () => { expect(await handler.test({ request: alienRequest })).toBe(false) }) - test('respects operation name', async () => { + it('respects operation name', async () => { const handler = new GraphQLHandler( OperationTypeNode.QUERY, 'GetUser', @@ -696,11 +731,11 @@ describe('test', () => { `, }) - expect(await handler.test({ request })).toBe(true) - expect(await handler.test({ request: alienRequest })).toBe(false) + await expect(handler.test({ request })).resolves.toBe(true) + await expect(handler.test({ request: alienRequest })).resolves.toBe(false) }) - test('respects custom endpoint', async () => { + it('respects custom endpoint', async () => { const handler = new GraphQLHandler( OperationTypeNode.QUERY, 'GetUser', @@ -717,13 +752,13 @@ describe('test', () => { query: GET_USER, }) - expect(await handler.test({ request })).toBe(true) - expect(await handler.test({ request: alienRequest })).toBe(false) + await expect(handler.test({ request })).resolves.toBe(true) + await expect(handler.test({ request: alienRequest })).resolves.toBe(false) }) }) describe('run', () => { - test('returns a mocked response given a matching query', async () => { + it('returns a mocked response given a matching query', async () => { const handler = new GraphQLHandler( OperationTypeNode.QUERY, 'GetUser', @@ -757,18 +792,18 @@ describe('run', () => { }) expect(result!.request.method).toBe('POST') expect(result!.request.url).toBe('https://example.com/') - expect(await result!.request.json()).toEqual({ + await expect(result!.request.json()).resolves.toEqual({ query: GET_USER, variables: { userId: 'abc-123' }, }) expect(result!.response?.status).toBe(200) expect(result!.response?.statusText).toBe('OK') - expect(await result!.response?.json()).toEqual({ + await expect(result!.response?.json()).resolves.toEqual({ data: { user: { id: 'abc-123' } }, }) }) - test('returns null given a non-matching query', async () => { + it('returns null given a non-matching query', async () => { const handler = new GraphQLHandler( OperationTypeNode.QUERY, 'GetUser', @@ -818,12 +853,12 @@ describe('request', () => { ) const request = createPostGraphQLRequest({ query: ` - query GetAllUsers { - user { - id - } + query GetAllUsers { + user { + id } - `, + } + `, }) const requestId = createRequestId() diff --git a/src/core/handlers/GraphQLHandler.ts b/src/core/handlers/GraphQLHandler.ts index 8aebee5e3..7d523ca2d 100644 --- a/src/core/handlers/GraphQLHandler.ts +++ b/src/core/handlers/GraphQLHandler.ts @@ -21,15 +21,15 @@ import { toPublicUrl } from '../utils/request/toPublicUrl' import { devUtils } from '../utils/internal/devUtils' import { getAllRequestCookies } from '../utils/request/getRequestCookies' -export type ExpectedOperationTypeNode = OperationTypeNode | 'all' +export type GraphQLOperationType = OperationTypeNode | 'all' export type GraphQLHandlerNameSelector = DocumentNode | RegExp | string export type GraphQLQuery = Record | null export type GraphQLVariables = Record export interface GraphQLHandlerInfo extends RequestHandlerDefaultInfo { - operationType: ExpectedOperationTypeNode - operationName: GraphQLHandlerNameSelector + operationType: GraphQLOperationType + operationName: GraphQLHandlerNameSelector | GraphQLCustomPredicate } export type GraphQLRequestParsedResult = { @@ -77,6 +77,21 @@ export type GraphQLResponseBody = | null | undefined +export type GraphQLCustomPredicate = (args: { + request: Request + query: string + operationType: GraphQLOperationType + operationName: string + variables: GraphQLVariables + cookies: Record +}) => GraphQLCustomPredicateResult | Promise + +export type GraphQLCustomPredicateResult = boolean | { matches: boolean } + +export type GraphQLPredicate = + | GraphQLHandlerNameSelector + | GraphQLCustomPredicate + export function isDocumentNode( value: DocumentNode | any, ): value is DocumentNode { @@ -100,16 +115,16 @@ export class GraphQLHandler extends RequestHandler< >() constructor( - operationType: ExpectedOperationTypeNode, - operationName: GraphQLHandlerNameSelector, + operationType: GraphQLOperationType, + predicate: GraphQLPredicate, endpoint: Path, resolver: ResponseResolver, any, any>, options?: RequestHandlerOptions, ) { - let resolvedOperationName = operationName + let resolvedOperationName = predicate - if (isDocumentNode(operationName)) { - const parsedNode = parseDocumentNode(operationName) + if (isDocumentNode(resolvedOperationName)) { + const parsedNode = parseDocumentNode(resolvedOperationName) if (parsedNode.operationType !== operationType) { throw new Error( @@ -126,10 +141,15 @@ export class GraphQLHandler extends RequestHandler< resolvedOperationName = parsedNode.operationName } + const displayOperationName = + typeof resolvedOperationName === 'function' + ? '[custom predicate]' + : resolvedOperationName + const header = operationType === 'all' ? `${operationType} (origin: ${endpoint.toString()})` - : `${operationType} ${resolvedOperationName} (origin: ${endpoint.toString()})` + : `${operationType}${displayOperationName ? ` ${displayOperationName}` : ''} (origin: ${endpoint.toString()})` super({ info: { @@ -175,7 +195,10 @@ export class GraphQLHandler extends RequestHandler< const cookies = getAllRequestCookies(args.request) if (!match.matches) { - return { match, cookies } + return { + match, + cookies, + } } const parsedResult = await this.parseGraphQLRequestOrGetFromCache( @@ -183,7 +206,10 @@ export class GraphQLHandler extends RequestHandler< ) if (typeof parsedResult === 'undefined') { - return { match, cookies } + return { + match, + cookies, + } } return { @@ -196,10 +222,10 @@ export class GraphQLHandler extends RequestHandler< } } - predicate(args: { + async predicate(args: { request: Request parsedResult: GraphQLRequestParsedResult - }) { + }): Promise { if (args.parsedResult.operationType === undefined) { return false } @@ -218,10 +244,16 @@ Consider naming this operation or using "graphql.operation()" request handler to 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 + /** + * Check if the operation name matches the outgoing GraphQL request. + * @note Unlike the HTTP handler, the custom predicate functions are invoked + * during predicate, not parsing, because GraphQL request parsing happens first, + * and non-GraphQL requests are filtered out automatically. + */ + const hasMatchingOperationName = await this.matchOperationName({ + request: args.request, + parsedResult: args.parsedResult, + }) return ( args.parsedResult.match.matches && @@ -230,12 +262,44 @@ Consider naming this operation or using "graphql.operation()" request handler to ) } + private async matchOperationName(args: { + request: Request + parsedResult: GraphQLRequestParsedResult + }): Promise { + if (typeof this.info.operationName === 'function') { + const customPredicateResult = await this.info.operationName({ + request: args.request, + ...this.extendResolverArgs({ + request: args.request, + parsedResult: args.parsedResult, + }), + }) + + /** + * @note Keep the { matches } signature in case we decide to support path parameters + * in GraphQL handlers. If that happens, the custom predicate would have to be moved + * to the parsing phase, the same as we have for the HttpHandler, and the user will + * have a possibility to return parsed path parameters from the custom predicate. + */ + return typeof customPredicateResult === 'boolean' + ? customPredicateResult + : customPredicateResult.matches + } + + if (this.info.operationName instanceof RegExp) { + return this.info.operationName.test(args.parsedResult.operationName || '') + } + + return args.parsedResult.operationName === this.info.operationName + } + protected extendResolverArgs(args: { request: Request parsedResult: GraphQLRequestParsedResult }) { return { query: args.parsedResult.query || '', + operationType: args.parsedResult.operationType!, operationName: args.parsedResult.operationName || '', variables: args.parsedResult.variables || {}, cookies: args.parsedResult.cookies, diff --git a/src/core/handlers/HttpHandler.test.ts b/src/core/handlers/HttpHandler.test.ts index 6c01e4703..8f8a3d558 100644 --- a/src/core/handlers/HttpHandler.test.ts +++ b/src/core/handlers/HttpHandler.test.ts @@ -1,6 +1,4 @@ -/** - * @vitest-environment jsdom - */ +// @vitest-environment jsdom import { createRequestId } from '@mswjs/interceptors' import { HttpHandler, HttpRequestResolverExtras } from './HttpHandler' import { HttpResponse } from '..' @@ -13,7 +11,7 @@ const resolver: ResponseResolver< } describe('info', () => { - test('exposes request handler information', () => { + it('exposes request handler information', () => { const handler = new HttpHandler('GET', '/user/:userId', resolver) expect(handler.info.header).toEqual('GET /user/:userId') @@ -24,7 +22,7 @@ describe('info', () => { }) describe('parse', () => { - test('parses a URL given a matching request', async () => { + it('parses a URL given a matching request', async () => { const handler = new HttpHandler('GET', '/user/:userId', resolver) const request = new Request(new URL('/user/abc-123', location.href)) @@ -39,7 +37,7 @@ describe('parse', () => { }) }) - test('parses a URL and ignores the request method', async () => { + it('parses a URL and ignores the request method', async () => { const handler = new HttpHandler('GET', '/user/:userId', resolver) const request = new Request(new URL('/user/def-456', location.href), { method: 'POST', @@ -56,7 +54,7 @@ describe('parse', () => { }) }) - test('returns negative match result given a non-matching request', async () => { + it('returns negative match result given a non-matching request', async () => { const handler = new HttpHandler('GET', '/user/:userId', resolver) const request = new Request(new URL('/login', location.href)) @@ -71,21 +69,21 @@ describe('parse', () => { }) describe('predicate', () => { - test('returns true given a matching request', async () => { + it('returns true given a matching request', async () => { const handler = new HttpHandler('POST', '/login', resolver) const request = new Request(new URL('/login', location.href), { method: 'POST', }) - expect( + await expect( handler.predicate({ request, parsedResult: await handler.parse({ request }), }), - ).toBe(true) + ).resolves.toBe(true) }) - test('respects RegExp as the request method', async () => { + it('supports RegExp as the request method', async () => { const handler = new HttpHandler(/.+/, '/login', resolver) const requests = [ new Request(new URL('/login', location.href)), @@ -94,30 +92,60 @@ describe('predicate', () => { ] for (const request of requests) { - expect( + await expect( handler.predicate({ request, parsedResult: await handler.parse({ request }), }), - ).toBe(true) + ).resolves.toBe(true) } }) - test('returns false given a non-matching request', async () => { + it('returns false given a non-matching request', async () => { const handler = new HttpHandler('POST', '/login', resolver) const request = new Request(new URL('/user/abc-123', location.href)) - expect( + await expect( handler.predicate({ request, parsedResult: await handler.parse({ request }), }), - ).toBe(false) + ).resolves.toBe(false) + }) + + it('supports custom predicate function', async () => { + const handler = new HttpHandler( + 'GET', + ({ request }) => { + return new URL(request.url).searchParams.get('a') === '1' + }, + resolver, + ) + + { + const request = new Request(new URL('/login?a=1', location.href)) + await expect( + handler.predicate({ + request, + parsedResult: await handler.parse({ request }), + }), + ).resolves.toBe(true) + } + + { + const request = new Request(new URL('/login', location.href)) + await expect( + handler.predicate({ + request, + parsedResult: await handler.parse({ request }), + }), + ).resolves.toBe(false) + } }) }) describe('test', () => { - test('returns true given a matching request', async () => { + it('returns true given a matching request', async () => { const handler = new HttpHandler('GET', '/user/:userId', resolver) const firstTest = await handler.test({ request: new Request(new URL('/user/abc-123', location.href)), @@ -130,7 +158,7 @@ describe('test', () => { expect(secondTest).toBe(true) }) - test('returns false given a non-matching request', async () => { + it('returns false given a non-matching request', async () => { const handler = new HttpHandler('GET', '/user/:userId', resolver) const firstTest = await handler.test({ request: new Request(new URL('/login', location.href)), @@ -149,7 +177,7 @@ describe('test', () => { }) describe('run', () => { - test('returns a mocked response given a matching request', async () => { + it('returns a mocked response given a matching request', async () => { const handler = new HttpHandler('GET', '/user/:userId', resolver) const request = new Request(new URL('/user/abc-123', location.href)) const requestId = createRequestId() @@ -169,10 +197,12 @@ describe('run', () => { expect(result!.request.url).toBe('http://localhost/user/abc-123') expect(result!.response?.status).toBe(200) expect(result!.response?.statusText).toBe('OK') - expect(await result?.response?.json()).toEqual({ userId: 'abc-123' }) + await expect(result?.response?.json()).resolves.toEqual({ + userId: 'abc-123', + }) }) - test('returns null given a non-matching request', async () => { + it('returns null given a non-matching request', async () => { const handler = new HttpHandler('POST', '/login', resolver) const result = await handler.run({ request: new Request(new URL('/users', location.href)), @@ -182,7 +212,7 @@ describe('run', () => { expect(result).toBeNull() }) - test('returns an empty "params" object given request with no URL parameters', async () => { + it('returns an empty "params" object given request with no URL parameters', async () => { const handler = new HttpHandler('GET', '/users', resolver) const result = await handler.run({ request: new Request(new URL('/users', location.href)), @@ -192,7 +222,7 @@ describe('run', () => { expect(result?.parsedResult?.match?.params).toEqual({}) }) - test('exhausts resolver until its generator completes', async () => { + it('exhausts resolver until its generator completes', async () => { const handler = new HttpHandler('GET', '/users', function* () { let count = 0 @@ -212,12 +242,12 @@ describe('run', () => { return result?.response?.text() } - expect(await run()).toBe('pending') - expect(await run()).toBe('pending') - expect(await run()).toBe('pending') - expect(await run()).toBe('pending') - expect(await run()).toBe('pending') - expect(await run()).toBe('complete') - expect(await run()).toBe('complete') + await expect(run()).resolves.toBe('pending') + await expect(run()).resolves.toBe('pending') + await expect(run()).resolves.toBe('pending') + await expect(run()).resolves.toBe('pending') + await expect(run()).resolves.toBe('pending') + await expect(run()).resolves.toBe('complete') + await expect(run()).resolves.toBe('complete') }) }) diff --git a/src/core/handlers/HttpHandler.ts b/src/core/handlers/HttpHandler.ts index 2b80e3b0a..49ef8a533 100644 --- a/src/core/handlers/HttpHandler.ts +++ b/src/core/handlers/HttpHandler.ts @@ -25,7 +25,7 @@ type HttpHandlerMethod = string | RegExp export interface HttpHandlerInfo extends RequestHandlerDefaultInfo { method: HttpHandlerMethod - path: Path + path: HttpRequestPredicate } export enum HttpMethods { @@ -52,6 +52,24 @@ export type HttpRequestResolverExtras = { cookies: Record } +export type HttpCustomPredicate = (args: { + request: Request + cookies: Record +}) => + | HttpCustomPredicateResult + | Promise> + +export type HttpCustomPredicateResult = + | boolean + | { + matches: boolean + params: Params + } + +export type HttpRequestPredicate = + | Path + | HttpCustomPredicate + /** * Request handler for HTTP requests. * Provides request matching based on method and URL. @@ -63,14 +81,17 @@ export class HttpHandler extends RequestHandler< > { constructor( method: HttpHandlerMethod, - path: Path, + predicate: HttpRequestPredicate, resolver: ResponseResolver, any, any>, options?: RequestHandlerOptions, ) { + const displayPath = + typeof predicate === 'function' ? '[custom predicate]' : predicate + super({ info: { - header: `${method} ${path}`, - path, + header: `${method}${displayPath ? ` ${displayPath}` : ''}`, + path: predicate, method, }, resolver, @@ -83,7 +104,7 @@ export class HttpHandler extends RequestHandler< private checkRedundantQueryParameters() { const { method, path } = this.info - if (path instanceof RegExp) { + if (!path || path instanceof RegExp || typeof path === 'function') { return } @@ -95,7 +116,7 @@ export class HttpHandler extends RequestHandler< } const searchParams = getSearchParams(path) - const queryParams: string[] = [] + const queryParams: Array = [] searchParams.forEach((_, paramName) => { queryParams.push(paramName) @@ -111,20 +132,48 @@ 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 cookies = getAllRequestCookies(args.request) + /** + * Handle custom predicate functions. + * @note Invoke this during parsing so the user can parse the path parameters + * manually. Otherwise, `params` is always an empty object, which isn't nice. + */ + if (typeof this.info.path === 'function') { + const customPredicateResult = await this.info.path({ + request: args.request, + cookies, + }) + + const match = + typeof customPredicateResult === 'boolean' + ? { + matches: customPredicateResult, + params: {}, + } + : customPredicateResult + + return { + match, + cookies, + } + } + + const match = this.info.path + ? matchRequestUrl(url, this.info.path, args.resolutionContext?.baseUrl) + : { matches: false, params: {} } + return { match, cookies, } } - predicate(args: { request: Request; parsedResult: HttpRequestParsedResult }) { + async predicate(args: { + request: Request + parsedResult: HttpRequestParsedResult + resolutionContext?: ResponseResolutionContext + }) { const hasMatchingMethod = this.matchMethod(args.request.method) const hasMatchingUrl = args.parsedResult.match.matches return hasMatchingMethod && hasMatchingUrl diff --git a/src/core/handlers/RequestHandler.ts b/src/core/handlers/RequestHandler.ts index d7ed8c834..bc240ebbc 100644 --- a/src/core/handlers/RequestHandler.ts +++ b/src/core/handlers/RequestHandler.ts @@ -179,7 +179,7 @@ export abstract class RequestHandler< request: Request parsedResult: ParsedResult resolutionContext?: ResponseResolutionContext - }): boolean + }): boolean | Promise /** * Print out the successfully handled request. @@ -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, diff --git a/src/core/http.ts b/src/core/http.ts index b775b0f23..2d9ac7742 100644 --- a/src/core/http.ts +++ b/src/core/http.ts @@ -7,8 +7,9 @@ import { HttpMethods, HttpHandler, HttpRequestResolverExtras, + HttpRequestPredicate, } from './handlers/HttpHandler' -import type { Path, PathParams } from './utils/matching/matchRequestUrl' +import type { PathParams } from './utils/matching/matchRequestUrl' export type HttpRequestHandler = < Params extends PathParams = PathParams, @@ -18,9 +19,8 @@ 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, >( - path: RequestPath, + predicate: HttpRequestPredicate, resolver: HttpResponseResolver, options?: RequestHandlerOptions, ) => HttpHandler @@ -38,8 +38,8 @@ export type HttpResponseResolver< function createHttpHandler( method: Method, ): HttpRequestHandler { - return (path, resolver, options = {}) => { - return new HttpHandler(method, path, resolver, options) + return (predicate, resolver, options = {}) => { + return new HttpHandler(method, predicate, resolver, options) } } diff --git a/src/core/index.ts b/src/core/index.ts index aafc36394..88012b0a8 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -42,6 +42,7 @@ export type { export type { RequestQuery, HttpRequestParsedResult, + HttpCustomPredicate, } from './handlers/HttpHandler' export type { HttpRequestHandler, HttpResponseResolver } from './http' @@ -51,6 +52,8 @@ export type { GraphQLRequestBody, GraphQLResponseBody, GraphQLJsonRequestBody, + GraphQLOperationType, + GraphQLCustomPredicate, } from './handlers/GraphQLHandler' export type { GraphQLRequestHandler, GraphQLResponseResolver } from './graphql' diff --git a/src/core/utils/handleRequest.test.ts b/src/core/utils/handleRequest.test.ts index 932276450..8b452fa87 100644 --- a/src/core/utils/handleRequest.test.ts +++ b/src/core/utils/handleRequest.test.ts @@ -487,3 +487,87 @@ describe('[Private] - resolutionContext - used for extensions', () => { }) }) }) + +describe('handler with custom predicate', () => { + test('matches if custom predicate returns true', async () => { + const { emitter, events } = setup() + + const requestId = createRequestId() + const request = new Request(new URL('http://localhost/login'), { + method: 'POST', + body: JSON.stringify({ username: 'test', password: 'password' }), + headers: { 'Content-Type': 'application/json' }, + }) + const handlers: Array = [ + http.post( + async ({ request }) => { + const body = await request.clone().json() + return body.username === 'test' && body.password === 'password' + }, + () => + HttpResponse.json({ + success: true, + }), + ), + ] + + const result = await handleRequest( + request, + requestId, + handlers, + options, + emitter, + handleRequestOptions, + ) + + expect(result).toBeDefined() + expect(await result?.json()).toStrictEqual({ success: true }) + expect(events).toEqual([ + ['request:start', { request, requestId }], + ['request:match', { request, requestId }], + ['request:end', { request, requestId }], + ]) + expect(handleRequestOptions.onMockedResponse).toHaveBeenCalledTimes(1) + }) + + test('does not match if custom predicate returns false', async () => { + const { emitter, events } = setup() + + const requestId = createRequestId() + const request = new Request(new URL('http://localhost/login'), { + method: 'POST', + body: JSON.stringify({ username: 'test', password: 'passwordd' }), + headers: { 'Content-Type': 'application/json' }, + }) + const handlers: Array = [ + http.post( + async ({ request }) => { + const body = await request.clone().json() + return body.username === 'test' && body.password === 'password' + }, + () => + HttpResponse.json({ + success: true, + }), + ), + ] + + const result = await handleRequest( + request, + requestId, + handlers, + options, + emitter, + handleRequestOptions, + ) + + expect(result).toBeUndefined() + expect(events).toEqual([ + ['request:start', { request, requestId }], + ['request:unhandled', { request, requestId }], + ['request:end', { request, requestId }], + ]) + expect(options.onUnhandledRequest).toHaveBeenCalledTimes(1) + expect(handleRequestOptions.onPassthroughResponse).toHaveBeenCalledTimes(1) + }) +}) diff --git a/test/browser/graphql-api/custom-predicate.mocks.ts b/test/browser/graphql-api/custom-predicate.mocks.ts new file mode 100644 index 000000000..5bb985efc --- /dev/null +++ b/test/browser/graphql-api/custom-predicate.mocks.ts @@ -0,0 +1,11 @@ +import { graphql } from 'msw' +import { setupWorker } from 'msw/browser' + +const worker = setupWorker() +worker.start() + +window.msw = { + // @ts-ignore + worker, + graphql, +} diff --git a/test/browser/graphql-api/custom-predicate.test.ts b/test/browser/graphql-api/custom-predicate.test.ts new file mode 100644 index 000000000..3a0e4d3f2 --- /dev/null +++ b/test/browser/graphql-api/custom-predicate.test.ts @@ -0,0 +1,110 @@ +import { graphql } from 'msw' +import { SetupWorkerApi } from 'msw/browser' +import { gql } from '../../support/graphql' +import { test, expect } from '../playwright.extend' + +declare namespace window { + export const msw: { + worker: SetupWorkerApi + graphql: typeof graphql + } +} + +const PREDICATE_EXAMPLE = new URL( + './custom-predicate.mocks.ts', + import.meta.url, +) + +test('matches requests when the predicate function returns true', async ({ + loadExample, + query, + page, +}) => { + await loadExample(PREDICATE_EXAMPLE) + + await page.evaluate(() => { + const { worker, graphql } = window.msw + + worker.use( + graphql.query( + ({ variables }) => { + return variables.id === 'abc-123' + }, + ({ variables }) => { + return Response.json({ + data: { user: { id: variables.id } }, + }) + }, + ), + ) + }) + + const response = await query('/irrelevant', { + query: gql` + query GetUser($id: String!) { + user(id: $id) { + id + } + } + `, + variables: { + id: 'abc-123', + }, + }) + + expect(response.status()).toBe(200) + await expect(response.json()).resolves.toEqual({ + data: { + user: { + id: 'abc-123', + }, + }, + }) +}) + +test('does not match requests when the predicate function returns false', async ({ + loadExample, + query, + page, +}) => { + await loadExample(PREDICATE_EXAMPLE) + + await page.evaluate(() => { + const { worker, graphql } = window.msw + + worker.use( + graphql.query( + ({ variables }) => { + return variables.id === 'abc-123' + }, + ({ variables }) => { + return Response.json({ + data: { user: { id: variables.id } }, + }) + }, + ), + graphql.operation(() => { + return Response.json({ data: { fallback: true } }) + }), + ) + }) + + const response = await query('/irrelevant', { + query: gql` + query GetUser($id: String!) { + user(id: $id) { + id + } + } + `, + variables: { + id: 'non-matching-query', + }, + }) + + await expect(response.json()).resolves.toEqual({ + data: { + fallback: true, + }, + }) +}) diff --git a/test/browser/playwright.extend.ts b/test/browser/playwright.extend.ts index 8606b3047..0aeecc5e9 100644 --- a/test/browser/playwright.extend.ts +++ b/test/browser/playwright.extend.ts @@ -198,7 +198,7 @@ export const test = base.extend({ }, async query({ page }, use) { await use(async (uri, options) => { - const requestId = crypto.createHash('md5').digest('hex') + const requestId = crypto.randomUUID() const method = options.method || 'POST' const requestUrl = new URL(uri, 'http://localhost:8080') const headers: FlatHeadersObject = { diff --git a/test/browser/rest-api/request/matching/custom-predicate.mocks.ts b/test/browser/rest-api/request/matching/custom-predicate.mocks.ts new file mode 100644 index 000000000..f3f46a72d --- /dev/null +++ b/test/browser/rest-api/request/matching/custom-predicate.mocks.ts @@ -0,0 +1,11 @@ +import { http } from 'msw' +import { setupWorker } from 'msw/browser' + +const worker = setupWorker() +worker.start() + +window.msw = { + // @ts-ignore + worker, + http, +} diff --git a/test/browser/rest-api/request/matching/custom-predicate.test.ts b/test/browser/rest-api/request/matching/custom-predicate.test.ts new file mode 100644 index 000000000..be4a89ef9 --- /dev/null +++ b/test/browser/rest-api/request/matching/custom-predicate.test.ts @@ -0,0 +1,78 @@ +import { http } from 'msw' +import { SetupWorkerApi } from 'msw/browser' +import { test, expect } from '../../../playwright.extend' + +declare namespace window { + export const msw: { + worker: SetupWorkerApi + http: typeof http + } +} + +const PREDICATE_EXAMPLE = new URL( + './custom-predicate.mocks.ts', + import.meta.url, +) + +test('matches the request when the predicate function returns true', async ({ + loadExample, + fetch, + page, +}) => { + await loadExample(PREDICATE_EXAMPLE) + + await page.evaluate(() => { + const { worker, http } = window.msw + + worker.use( + http.post( + async ({ request }) => { + const requestBody = await request.clone().text() + return requestBody === 'hello world' + }, + ({ request }) => { + return new Response(request.clone().body, request) + }, + ), + ) + }) + + const response = await fetch('/irrelevant', { + method: 'POST', + body: 'hello world', + }) + + expect.soft(response.status()).toBe(200) + await expect.soft(response.text()).resolves.toBe('hello world') +}) + +test('does not match the request when the predicate function returns false', async ({ + loadExample, + fetch, + page, +}) => { + await loadExample(PREDICATE_EXAMPLE) + + await page.evaluate(() => { + const { worker, http } = window.msw + + worker.use( + http.post( + async ({ request }) => { + const requestBody = await request.clone().text() + return requestBody === 'hello world' + }, + ({ request }) => { + return new Response(request.clone().body, request) + }, + ), + ) + }) + + const response = await fetch('/irrelevant', { + method: 'POST', + body: 'non-matching-request', + }) + + expect(response.status()).toBe(404) +}) diff --git a/test/node/graphql-api/custom-predicate.node.test.ts b/test/node/graphql-api/custom-predicate.node.test.ts new file mode 100644 index 000000000..da9279df4 --- /dev/null +++ b/test/node/graphql-api/custom-predicate.node.test.ts @@ -0,0 +1,77 @@ +// @vitest-environment node +import { graphql, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { gql } from '../../support/graphql' + +const server = setupServer() + +beforeAll(() => { + server.listen({ onUnhandledRequest: 'bypass' }) +}) + +afterEach(() => { + server.resetHandlers() +}) + +afterAll(() => { + server.close() +}) + +it('matches requests when the predicate function returns true', async () => { + server.use( + graphql.query( + ({ operationName }) => { + return operationName.toLowerCase().includes('user') + }, + () => { + return HttpResponse.json({ data: { user: 1 } }) + }, + ), + ) + + const response = await fetch('http://localhost/irrelevant', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: gql` + query GetUser { + user { + firstName + } + } + `, + }), + }) + + expect.soft(response.status).toBe(200) + await expect.soft(response.json()).resolves.toEqual({ data: { user: 1 } }) +}) + +it('does not match requests when the predicate function returns false', async () => { + server.use( + graphql.query( + ({ operationName }) => { + return operationName.toLowerCase().includes('user') + }, + () => { + return HttpResponse.json({ data: { user: 1 } }) + }, + ), + ) + + await expect( + fetch('http://localhost/irrelevant', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: gql` + query GetCart { + cart { + id + } + } + `, + }), + }), + ).rejects.toThrow('fetch failed') +}) diff --git a/test/node/rest-api/request/matching/custom-predicate.node.test.ts b/test/node/rest-api/request/matching/custom-predicate.node.test.ts new file mode 100644 index 000000000..cf4ee711d --- /dev/null +++ b/test/node/rest-api/request/matching/custom-predicate.node.test.ts @@ -0,0 +1,60 @@ +// @vitest-environment node +import { http } from 'msw' +import { setupServer } from 'msw/node' + +const server = setupServer() + +beforeAll(() => { + server.listen({ onUnhandledRequest: 'bypass' }) +}) + +afterEach(() => { + server.resetHandlers() +}) + +afterAll(() => { + server.close() +}) + +it('matches requests when the predicate function returns true', async () => { + server.use( + http.post( + async ({ request }) => { + const requestBody = await request.clone().text() + return requestBody === 'hello world' + }, + async ({ request }) => { + return new Response(request.clone().body, request) + }, + ), + ) + + const response = await fetch('http://localhost/irrelevant', { + method: 'POST', + body: 'hello world', + }) + + expect.soft(response.status).toBe(200) + await expect.soft(response.text()).resolves.toBe('hello world') +}) + +it('does not match requests when the predicate function returns false', async () => { + server.use( + http.post( + async ({ request }) => { + const requestBody = await request.clone().text() + return requestBody === 'hello world' + }, + async ({ request }) => { + return new Response(request.clone().body, request) + }, + ), + ) + + await expect( + fetch('http://localhost/irrelevant', { + method: 'POST', + body: 'non-matching-request', + }), + ).rejects.toThrow('fetch failed') +}) diff --git a/test/typings/graphql-custom-predicate.test-d.ts b/test/typings/graphql-custom-predicate.test-d.ts new file mode 100644 index 000000000..13e5f802d --- /dev/null +++ b/test/typings/graphql-custom-predicate.test-d.ts @@ -0,0 +1,72 @@ +import { + graphql, + type GraphQLOperationType, + type GraphQLVariables, + type GraphQLResponseResolver, +} from 'msw' + +const resolver: GraphQLResponseResolver = () => void 0 + +it('supports custom predicate', () => { + graphql.query<{ user: null }, { a: string }>( + ({ request, cookies, operationType, operationName, query, variables }) => { + expectTypeOf(request).toEqualTypeOf() + expectTypeOf(cookies).toEqualTypeOf>() + expectTypeOf(operationType).toEqualTypeOf + expectTypeOf(operationName).toEqualTypeOf() + /** + * @note Both query and variables do not infer the narrow type from the handler + * because this is the matching phase and values might be arbitrary. + */ + expectTypeOf(query).toEqualTypeOf() + expectTypeOf(variables).toEqualTypeOf() + + return operationName === 'MyQuery' + }, + resolver, + ) + + graphql.query(() => true, resolver) + graphql.query(() => false, resolver) + graphql.query( + // @ts-expect-error Invalid return type. + () => {}, + resolver, + ) + graphql.query( + // @ts-expect-error Invalid return type. + () => ({}), + resolver, + ) + graphql.query( + // @ts-expect-error Invalid return type. + () => undefined, + resolver, + ) + graphql.query( + // @ts-expect-error Invalid return type. + () => null, + resolver, + ) +}) + +it('supports returning extended match result from a custom predicate', () => { + graphql.query(() => ({ matches: true }), resolver) + graphql.query(() => ({ matches: false }), resolver) + + graphql.query( + // @ts-expect-error Invalid return type. + () => ({ matches: 2 }), + resolver, + ) + graphql.query( + // @ts-expect-error Invalid return type. + () => ({ matches: undefined }), + resolver, + ) + graphql.query( + // @ts-expect-error Invalid return type. + () => ({ matches: null }), + resolver, + ) +}) diff --git a/test/typings/http-custom-predicate.test-d.ts b/test/typings/http-custom-predicate.test-d.ts new file mode 100644 index 000000000..47d4a320b --- /dev/null +++ b/test/typings/http-custom-predicate.test-d.ts @@ -0,0 +1,57 @@ +import { http, HttpResponseResolver } from 'msw' + +const resolver: HttpResponseResolver = () => void 0 + +it('supports custom predicate', () => { + http.get(({ request, cookies }) => { + expectTypeOf(request).toEqualTypeOf() + expectTypeOf(cookies).toEqualTypeOf>() + + return request.url.includes('user') + }, resolver) + + http.get(() => true, resolver) + http.get(() => false, resolver) + // @ts-expect-error Invalid return type. + http.get(() => {}, resolver) + // @ts-expect-error Invalid return type. + http.get(() => ({}), resolver) + // @ts-expect-error Invalid return type. + http.get(() => undefined, resolver) + // @ts-expect-error Invalid return type. + http.get(() => null, resolver) +}) + +it('supports returning path parameters from the custom predicate', () => { + // Implicit path parameters type. + http.get( + () => ({ + matches: true, + params: { user: 'hello' }, + }), + ({ params }) => { + expectTypeOf(params).toEqualTypeOf<{ user: string }>() + }, + ) + + // Explicit path parameters type. + http.get<{ inferred: string }>( + () => ({ + matches: true, + params: { inferred: '1' }, + }), + ({ params }) => { + expectTypeOf(params).toEqualTypeOf<{ inferred: string }>() + }, + ) +}) + +it('supports returning extended match result from a custom predicate', () => { + http.get(() => ({ matches: true, params: {} }), resolver) + http.get(() => ({ matches: false, params: {} }), resolver) + + // @ts-expect-error Invalid return type. + http.get(() => ({ matches: true }), resolver) + // @ts-expect-error Invalid return type. + http.get(() => ({ params: {} }), resolver) +})