From 8624f31c464f61c35d6e53584b678994d9576fa8 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 12 Jan 2024 15:29:22 +0100 Subject: [PATCH] fix: improve custom request handler and resolver annotations (#1961) --- src/core/graphql.ts | 40 +++++---- src/core/handlers/GraphQLHandler.ts | 1 + src/core/handlers/RequestHandler.ts | 23 +++-- src/core/http.ts | 41 ++++++--- src/core/index.ts | 3 + test/typings/custom-handler.test-d.ts | 14 +++ test/typings/custom-resolver.test-d.ts | 85 +++++++++++++++++++ .../{rest.test-d.ts => http.test-d.ts} | 0 8 files changed, 168 insertions(+), 39 deletions(-) create mode 100644 test/typings/custom-handler.test-d.ts create mode 100644 test/typings/custom-resolver.test-d.ts rename test/typings/{rest.test-d.ts => http.test-d.ts} (100%) diff --git a/src/core/graphql.ts b/src/core/graphql.ts index b3409eb7f..5b432540c 100644 --- a/src/core/graphql.ts +++ b/src/core/graphql.ts @@ -10,6 +10,7 @@ import { GraphQLHandlerNameSelector, GraphQLResolverExtras, GraphQLResponseBody, + GraphQLQuery, } from './handlers/GraphQLHandler' import type { Path } from './utils/matching/matchRequestUrl' @@ -22,25 +23,32 @@ export interface TypedDocumentNode< __variablesType?: Variables } +export type GraphQLRequestHandler = < + Query extends GraphQLQuery = GraphQLQuery, + Variables extends GraphQLVariables = GraphQLVariables, +>( + operationName: + | GraphQLHandlerNameSelector + | DocumentNode + | TypedDocumentNode, + resolver: GraphQLResponseResolver, + options?: RequestHandlerOptions, +) => GraphQLHandler + +export type GraphQLResponseResolver< + Query extends GraphQLQuery = GraphQLQuery, + Variables extends GraphQLVariables = GraphQLVariables, +> = ResponseResolver< + GraphQLResolverExtras, + null, + GraphQLResponseBody +> + function createScopedGraphQLHandler( operationType: ExpectedOperationTypeNode, url: Path, -) { - return < - Query extends Record, - Variables extends GraphQLVariables = GraphQLVariables, - >( - operationName: - | GraphQLHandlerNameSelector - | DocumentNode - | TypedDocumentNode, - resolver: ResponseResolver< - GraphQLResolverExtras, - null, - GraphQLResponseBody - >, - options: RequestHandlerOptions = {}, - ) => { +): GraphQLRequestHandler { + return (operationName, resolver, options = {}) => { return new GraphQLHandler( operationType, operationName, diff --git a/src/core/handlers/GraphQLHandler.ts b/src/core/handlers/GraphQLHandler.ts index a588da9d3..d63715260 100644 --- a/src/core/handlers/GraphQLHandler.ts +++ b/src/core/handlers/GraphQLHandler.ts @@ -24,6 +24,7 @@ import { getAllRequestCookies } from '../utils/request/getRequestCookies' export type ExpectedOperationTypeNode = OperationTypeNode | 'all' export type GraphQLHandlerNameSelector = DocumentNode | RegExp | string +export type GraphQLQuery = Record export type GraphQLVariables = Record export interface GraphQLHandlerInfo extends RequestHandlerDefaultInfo { diff --git a/src/core/handlers/RequestHandler.ts b/src/core/handlers/RequestHandler.ts index 836b28681..082b4c41c 100644 --- a/src/core/handlers/RequestHandler.ts +++ b/src/core/handlers/RequestHandler.ts @@ -36,23 +36,28 @@ export interface RequestHandlerInternalInfo { } export type ResponseResolverReturnType< - BodyType extends DefaultBodyType = undefined, + ResponseBodyType extends DefaultBodyType = undefined, > = - | ([BodyType] extends [undefined] ? Response : StrictResponse) + | ([ResponseBodyType] extends [undefined] + ? Response + : StrictResponse) | undefined | void export type MaybeAsyncResponseResolverReturnType< - BodyType extends DefaultBodyType, -> = MaybePromise> + ResponseBodyType extends DefaultBodyType, +> = MaybePromise> -export type AsyncResponseResolverReturnType = - | MaybeAsyncResponseResolverReturnType +export type AsyncResponseResolverReturnType< + ResponseBodyType extends DefaultBodyType, +> = MaybePromise< + | ResponseResolverReturnType | Generator< - MaybeAsyncResponseResolverReturnType, - MaybeAsyncResponseResolverReturnType, - MaybeAsyncResponseResolverReturnType + MaybeAsyncResponseResolverReturnType, + MaybeAsyncResponseResolverReturnType, + MaybeAsyncResponseResolverReturnType > +> export type ResponseResolverInfo< ResolverExtraInfo extends Record, diff --git a/src/core/http.ts b/src/core/http.ts index d4f7c58f8..b775b0f23 100644 --- a/src/core/http.ts +++ b/src/core/http.ts @@ -10,22 +10,35 @@ import { } from './handlers/HttpHandler' import type { Path, PathParams } from './utils/matching/matchRequestUrl' +export type HttpRequestHandler = < + Params extends PathParams = PathParams, + RequestBodyType extends DefaultBodyType = DefaultBodyType, + // Response body type MUST be undefined by default. + // This is how we can distinguish between a handler that + // 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, + resolver: HttpResponseResolver, + options?: RequestHandlerOptions, +) => HttpHandler + +export type HttpResponseResolver< + Params extends PathParams = PathParams, + RequestBodyType extends DefaultBodyType = DefaultBodyType, + ResponseBodyType extends DefaultBodyType = DefaultBodyType, +> = ResponseResolver< + HttpRequestResolverExtras, + RequestBodyType, + ResponseBodyType +> + function createHttpHandler( method: Method, -) { - return < - Params extends PathParams = PathParams, - RequestBodyType extends DefaultBodyType = DefaultBodyType, - ResponseBodyType extends DefaultBodyType = undefined, - >( - path: Path, - resolver: ResponseResolver< - HttpRequestResolverExtras, - RequestBodyType, - ResponseBodyType - >, - options: RequestHandlerOptions = {}, - ) => { +): HttpRequestHandler { + return (path, resolver, options = {}) => { return new HttpHandler(method, path, resolver, options) } } diff --git a/src/core/index.ts b/src/core/index.ts index 582d12e0a..0269fbff6 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -34,12 +34,15 @@ export type { RequestQuery, HttpRequestParsedResult, } from './handlers/HttpHandler' +export type { HttpResponseResolver } from './http' export type { + GraphQLQuery, GraphQLVariables, GraphQLRequestBody, GraphQLJsonRequestBody, } from './handlers/GraphQLHandler' +export type { GraphQLResponseResolver } from './graphql' export type { Path, PathParams, Match } from './utils/matching/matchRequestUrl' export type { ParsedGraphQLRequest } from './utils/internal/parseGraphQLRequest' diff --git a/test/typings/custom-handler.test-d.ts b/test/typings/custom-handler.test-d.ts new file mode 100644 index 000000000..9e94760d1 --- /dev/null +++ b/test/typings/custom-handler.test-d.ts @@ -0,0 +1,14 @@ +import { http, HttpHandler, GraphQLHandler, graphql } from 'msw' +import { setupWorker } from 'msw/browser' +import { setupServer } from 'msw/node' + +function generateHttpHandler(): HttpHandler { + return http.get('/user', () => {}) +} + +function generateGraphQLHandler(): GraphQLHandler { + return graphql.query('GetUser', () => {}) +} + +setupWorker(generateHttpHandler(), generateGraphQLHandler()) +setupServer(generateHttpHandler(), generateGraphQLHandler()) diff --git a/test/typings/custom-resolver.test-d.ts b/test/typings/custom-resolver.test-d.ts new file mode 100644 index 000000000..89fc56767 --- /dev/null +++ b/test/typings/custom-resolver.test-d.ts @@ -0,0 +1,85 @@ +import { + http, + HttpResponseResolver, + delay, + PathParams, + DefaultBodyType, + HttpResponse, + graphql, + GraphQLQuery, + GraphQLVariables, + GraphQLResponseResolver, +} from 'msw' + +/** + * A higher-order resolver that injects a fixed + * delay before calling the provided resolver. + */ +function withDelay< + // Recreate the generic signature of the default resolver + // so the arguments passed to "http.get" propagate here. + Params extends PathParams, + RequestBodyType extends DefaultBodyType, + ResponseBodyType extends DefaultBodyType, +>( + delayMs: number, + resolver: HttpResponseResolver, +): HttpResponseResolver { + return async (...args) => { + await delay(delayMs) + return resolver(...args) + } +} + +http.get<{ id: string }, never, 'hello'>( + '/user/:id', + // @ts-expect-error Response body doesn't match the response type. + withDelay(250, ({ params }) => { + params.id.toUpperCase() + // @ts-expect-error Unknown path parameter. + params.nonexistent + + return HttpResponse.text('non-matching') + }), +) + +function identityGraphQLResolver< + Query extends GraphQLQuery, + Variables extends GraphQLVariables, +>( + resolver: GraphQLResponseResolver, +): GraphQLResponseResolver { + return async (...args) => { + return resolver(...args) + } +} + +graphql.query<{ number: number }, { id: string }>( + 'GetUser', + identityGraphQLResolver(({ variables }) => { + variables.id.toUpperCase() + + return HttpResponse.json({ + data: { + number: 1, + }, + }) + }), +) + +graphql.query<{ number: number }, { id: string }>( + 'GetUser', + // @ts-expect-error Incompatible response query type. + identityGraphQLResolver(({ variables }) => { + // @ts-expect-error Unknown variable. + variables.nonexistent + + return HttpResponse.json({ + data: { + user: { + id: variables.id, + }, + }, + }) + }), +) diff --git a/test/typings/rest.test-d.ts b/test/typings/http.test-d.ts similarity index 100% rename from test/typings/rest.test-d.ts rename to test/typings/http.test-d.ts