From 2bc794a8d6c1c4e534bd14ea4f5536e48edf03ea Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Tue, 30 Jun 2026 12:04:44 +0100 Subject: [PATCH 1/6] fix: improve v2 callable typing --- src/main.ts | 8 ++++---- src/v2.ts | 30 +++++++++++++++++++----------- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/src/main.ts b/src/main.ts index 13a23bba..6b64a6fd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -64,9 +64,9 @@ export { WrappedV2Function } from './v2'; export function wrap( cloudFunction: HttpsFunction & Runnable ): WrappedFunction>; -export function wrap( - cloudFunction: CallableFunction -): WrappedV2CallableFunction; +export function wrap( + cloudFunction: CallableFunction +): WrappedV2CallableFunction; export function wrap( cloudFunction: CloudFunctionV1 ): WrappedScheduledFunction | WrappedFunction; @@ -79,7 +79,7 @@ export function wrap>( | WrappedScheduledFunction | WrappedFunction | WrappedV2Function - | WrappedV2CallableFunction { + | WrappedV2CallableFunction { if (isV2CloudFunction(cloudFunction)) { return wrapV2(cloudFunction as CloudFunctionV2); } diff --git a/src/v2.ts b/src/v2.ts index e3fef189..a3509adc 100644 --- a/src/v2.ts +++ b/src/v2.ts @@ -21,11 +21,14 @@ // SOFTWARE. import { CloudFunction, CloudEvent } from 'firebase-functions/v2'; -import { CallableFunction, CallableRequest } from 'firebase-functions/v2/https'; +import { + CallableFunction, + CallableRequest, + Request, +} from 'firebase-functions/v2/https'; import { generateCombinedCloudEvent } from './cloudevent/generate'; import { DeepPartial } from './cloudevent/types'; -import * as express from 'express'; /** A function that can be called with test data and optional override values for {@link CloudEvent} * It will subsequently invoke the cloud function it wraps with the provided {@link CloudEvent} @@ -34,9 +37,14 @@ export type WrappedV2Function> = ( cloudEventPartial?: DeepPartial ) => any | Promise; -export type WrappedV2CallableFunction = ( - data: CallableRequest -) => T | Promise; +type WrappedV2CallableRequest = { + data: T; + rawRequest?: Partial; +} & Partial, 'data' | 'rawRequest'>>; + +export type WrappedV2CallableFunction = ( + data: WrappedV2CallableRequest +) => Return; function isCallableV2Function>( cf: CloudFunction | CallableFunction @@ -66,16 +74,16 @@ export function wrapV2>( * Takes a v2 HTTP function to be tested, and returns a {@link WrappedV2HttpsFunction} * which can be called in test code. */ -export function wrapV2( - cloudFunction: CallableFunction -): WrappedV2CallableFunction; +export function wrapV2( + cloudFunction: CallableFunction +): WrappedV2CallableFunction; export function wrapV2>( cloudFunction: CloudFunction | CallableFunction -): WrappedV2Function | WrappedV2CallableFunction { +): WrappedV2Function | WrappedV2CallableFunction { if (isCallableV2Function(cloudFunction)) { - return (req: CallableRequest) => { - return cloudFunction.run(req); + return (req: WrappedV2CallableRequest) => { + return cloudFunction.run(req as CallableRequest); }; } From 03c29068e16a50b2c882fa6817a1ea8d1c310b6f Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Tue, 30 Jun 2026 12:22:56 +0100 Subject: [PATCH 2/6] chore: cleanup and additional test --- spec/v2.spec.ts | 2 +- src/v2.ts | 17 ++++++++++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/spec/v2.spec.ts b/spec/v2.spec.ts index a9b22ff1..99729cbd 100644 --- a/spec/v2.spec.ts +++ b/spec/v2.spec.ts @@ -1428,7 +1428,7 @@ describe('v2', () => { }; const wrappedCF = wrapV2(cloudFunction); - const result = await wrappedCF({} as any); + const result = await wrappedCF(); expect(result).equal('hello'); }); }); diff --git a/src/v2.ts b/src/v2.ts index a3509adc..858448ea 100644 --- a/src/v2.ts +++ b/src/v2.ts @@ -37,13 +37,18 @@ export type WrappedV2Function> = ( cloudEventPartial?: DeepPartial ) => any | Promise; -type WrappedV2CallableRequest = { - data: T; +type WrappedV2CallableRequest = ([T] extends [undefined | void] + ? { data?: T } + : { data: T }) & { rawRequest?: Partial; } & Partial, 'data' | 'rawRequest'>>; +type WrappedV2CallableArgs = [T] extends [undefined | void] + ? [request?: WrappedV2CallableRequest] + : [request: WrappedV2CallableRequest]; + export type WrappedV2CallableFunction = ( - data: WrappedV2CallableRequest + ...args: WrappedV2CallableArgs ) => Return; function isCallableV2Function>( @@ -82,8 +87,10 @@ export function wrapV2>( cloudFunction: CloudFunction | CallableFunction ): WrappedV2Function | WrappedV2CallableFunction { if (isCallableV2Function(cloudFunction)) { - return (req: WrappedV2CallableRequest) => { - return cloudFunction.run(req as CallableRequest); + return (request?: WrappedV2CallableRequest) => { + return cloudFunction.run( + (request ?? { data: undefined }) as CallableRequest + ); }; } From b1e874bc5401ee6ae419e3ae037e9331f2ad97ee Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Thu, 15 Oct 2026 14:16:34 +0100 Subject: [PATCH 3/6] fix!: document and export WrappedV2CallableFunction, pin callable types in tests --- CHANGELOG.md | 1 + spec/v2.spec.ts | 37 +++++++++++++++++++++++++++++++++++++ src/main.ts | 2 +- src/v2.ts | 4 ++++ 4 files changed, 43 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38a3cca4..ce12018c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,3 +2,4 @@ - fix: support firebase-admin v14 by moving to the modular `firebase-admin/app` and `firebase-admin/firestore` entry points (#327) - breaking: drop firebase-admin `^8` and `^9` from the peer dependency range - breaking: type `makeDocumentSnapshot` as returning a `QueryDocumentSnapshot` when given data and a `DocumentSnapshot` when given `{}`, rather than `any` (#327) +- breaking: `WrappedV2CallableFunction` now takes the callable's request data and return types (`WrappedV2CallableFunction`), and wrapped v2 callables check the request against the callable's data type. Code that named `WrappedV2CallableFunction` should pass both type arguments (#257) diff --git a/spec/v2.spec.ts b/spec/v2.spec.ts index 99729cbd..b2628410 100644 --- a/spec/v2.spec.ts +++ b/spec/v2.spec.ts @@ -1431,6 +1431,43 @@ describe('v2', () => { const result = await wrappedCF(); expect(result).equal('hello'); }); + + it('should keep the callable request and return types', async () => { + const cloudFunction = https.onCall<{ a: number }, Promise>( + async (req) => `${req.data.a}` + ); + cloudFunction.__endpoint = { + platform: 'gcfv2', + labels: {}, + callableTrigger: {}, + }; + + const wrappedCF = wrapV2(cloudFunction); + const result: string = await wrappedCF({ data: { a: 1 } }); + expect(result).equal('1'); + + // @ts-expect-error wrong data type + () => wrappedCF({ data: { a: 'x' } }); + // @ts-expect-error request is required for typed callables + () => wrappedCF(); + // @ts-expect-error data is required for typed callables + () => wrappedCF({}); + }); + + it('should allow omitting the request when the callable takes no data', async () => { + const cloudFunction = https.onCall(() => 'hello'); + cloudFunction.__endpoint = { + platform: 'gcfv2', + labels: {}, + callableTrigger: {}, + }; + + const wrappedCF = wrapV2(cloudFunction); + expect(await wrappedCF()).equal('hello'); + expect( + await wrappedCF({ rawRequest: { headers: { 'x-test': '1' } } }) + ).equal('hello'); + }); }); describe('generated CloudEvent', () => { diff --git a/src/main.ts b/src/main.ts index 6b64a6fd..4576cfcd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -59,7 +59,7 @@ export { } from './v1'; // V2 Exports -export { WrappedV2Function } from './v2'; +export { WrappedV2Function, WrappedV2CallableFunction } from './v2'; export function wrap( cloudFunction: HttpsFunction & Runnable diff --git a/src/v2.ts b/src/v2.ts index 858448ea..d24cd665 100644 --- a/src/v2.ts +++ b/src/v2.ts @@ -47,6 +47,10 @@ type WrappedV2CallableArgs = [T] extends [undefined | void] ? [request?: WrappedV2CallableRequest] : [request: WrappedV2CallableRequest]; +/** A function that can be called with a {@link CallableRequest} for a v2 callable function. + * Every request field except `data` is optional, and the request itself can be omitted + * when the callable's data type is `undefined` or `void`. + */ export type WrappedV2CallableFunction = ( ...args: WrappedV2CallableArgs ) => Return; From 333c470f091a7504e7c6e17ea0d3af99ccc29ee5 Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Thu, 15 Oct 2026 14:53:03 +0100 Subject: [PATCH 4/6] fix: require a request when wrapping an untyped v2 callable --- spec/v2.spec.ts | 5 ++++- src/v2.ts | 11 +++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/spec/v2.spec.ts b/spec/v2.spec.ts index b2628410..ae372580 100644 --- a/spec/v2.spec.ts +++ b/spec/v2.spec.ts @@ -1428,8 +1428,11 @@ describe('v2', () => { }; const wrappedCF = wrapV2(cloudFunction); - const result = await wrappedCF(); + const result = await wrappedCF({ data: undefined }); expect(result).equal('hello'); + + // @ts-expect-error request is required for untyped callables + () => wrappedCF(); }); it('should keep the callable request and return types', async () => { diff --git a/src/v2.ts b/src/v2.ts index d24cd665..6e06f12b 100644 --- a/src/v2.ts +++ b/src/v2.ts @@ -37,13 +37,20 @@ export type WrappedV2Function> = ( cloudEventPartial?: DeepPartial ) => any | Promise; -type WrappedV2CallableRequest = ([T] extends [undefined | void] +// `any` also matches `undefined | void`, so untyped callables are excluded explicitly. +type TakesNoData = 0 extends 1 & T + ? false + : [T] extends [undefined | void] + ? true + : false; + +type WrappedV2CallableRequest = (TakesNoData extends true ? { data?: T } : { data: T }) & { rawRequest?: Partial; } & Partial, 'data' | 'rawRequest'>>; -type WrappedV2CallableArgs = [T] extends [undefined | void] +type WrappedV2CallableArgs = TakesNoData extends true ? [request?: WrappedV2CallableRequest] : [request: WrappedV2CallableRequest]; From 1845d02c4c920f8d32c11e10f8bb3cd21a00b926 Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Tue, 20 Oct 2026 12:13:48 +0100 Subject: [PATCH 5/6] fix: address review feedback on v2 callable typing --- spec/v2.spec.ts | 36 ++++++++++++++++++++++++++++++++++++ src/v2.ts | 13 +++++++------ 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/spec/v2.spec.ts b/spec/v2.spec.ts index ae372580..c8eeea6e 100644 --- a/spec/v2.spec.ts +++ b/spec/v2.spec.ts @@ -1471,6 +1471,42 @@ describe('v2', () => { await wrappedCF({ rawRequest: { headers: { 'x-test': '1' } } }) ).equal('hello'); }); + + it('should pass data as undefined when only request metadata is given', async () => { + const cloudFunction = https.onCall( + (req) => 'data' in req && req.data === undefined + ); + cloudFunction.__endpoint = { + platform: 'gcfv2', + labels: {}, + callableTrigger: {}, + }; + + const wrappedCF = wrapV2(cloudFunction); + expect(await wrappedCF()).equal(true); + expect( + await wrappedCF({ rawRequest: { headers: { 'x-test': '1' } } }) + ).equal(true); + }); + + it('should accept partial auth and app data', async () => { + const cloudFunction = https.onCall<{ a: number }, string>( + (req) => `${req.auth?.uid}:${req.app?.appId}` + ); + cloudFunction.__endpoint = { + platform: 'gcfv2', + labels: {}, + callableTrigger: {}, + }; + + const wrappedCF = wrapV2(cloudFunction); + const result = await wrappedCF({ + data: { a: 1 }, + auth: { uid: 'test-user' }, + app: { appId: 'test-app' }, + }); + expect(result).equal('test-user:test-app'); + }); }); describe('generated CloudEvent', () => { diff --git a/src/v2.ts b/src/v2.ts index c8d75b67..db7cd066 100644 --- a/src/v2.ts +++ b/src/v2.ts @@ -48,7 +48,7 @@ type WrappedV2CallableRequest = (TakesNoData extends true ? { data?: T } : { data: T }) & { rawRequest?: Partial; -} & Partial, 'data' | 'rawRequest'>>; +} & DeepPartial, 'data' | 'rawRequest'>>>; type WrappedV2CallableArgs = TakesNoData extends true ? [request?: WrappedV2CallableRequest] @@ -58,7 +58,7 @@ type WrappedV2CallableArgs = TakesNoData extends true * Every request field except `data` is optional, and the request itself can be omitted * when the callable's data type is `undefined` or `void`. */ -export type WrappedV2CallableFunction = ( +export type WrappedV2CallableFunction = ( ...args: WrappedV2CallableArgs ) => Return; @@ -89,7 +89,7 @@ export function wrapV2>( ): WrappedV2Function; /** - * Takes a v2 HTTP function to be tested, and returns a {@link WrappedV2HttpsFunction} + * Takes a v2 callable function to be tested, and returns a {@link WrappedV2CallableFunction} * which can be called in test code. */ export function wrapV2( @@ -101,9 +101,10 @@ export function wrapV2>( ): WrappedV2Function | WrappedV2CallableFunction { if (isCallableV2Function(cloudFunction)) { return (request?: WrappedV2CallableRequest) => { - return cloudFunction.run( - (request ?? { data: undefined }) as CallableRequest - ); + return cloudFunction.run({ + data: undefined, + ...request, + } as CallableRequest); }; } From 27e6a764a0700fbe74527a2c0edeb94c7ebcec5e Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Thu, 22 Oct 2026 12:14:03 +0100 Subject: [PATCH 6/6] fix: make every v2 callable request field optional Use a flat request type so tests can omit data or the whole request, wrap auth and app in NonNullable so the published types compile under strict, and add a test that fails when CallableRequest gains a field. --- spec/v2.spec.ts | 24 ++++++++++++++---------- src/v2.ts | 30 +++++++++++------------------- 2 files changed, 25 insertions(+), 29 deletions(-) diff --git a/spec/v2.spec.ts b/spec/v2.spec.ts index c8eeea6e..39f9861e 100644 --- a/spec/v2.spec.ts +++ b/spec/v2.spec.ts @@ -22,7 +22,7 @@ import { expect } from 'chai'; -import { wrapV2 } from '../src/v2'; +import { wrapV2, WrappedV2CallableRequest } from '../src/v2'; import { CloudFunction, @@ -36,6 +36,7 @@ import { https, firestore, } from 'firebase-functions/v2'; +import { CallableRequest } from 'firebase-functions/v2/https'; import { defineString } from 'firebase-functions/params'; import { makeDataSnapshot } from '../src/providers/database'; import { makeDocumentSnapshot } from '../src/providers/firestore'; @@ -1428,11 +1429,8 @@ describe('v2', () => { }; const wrappedCF = wrapV2(cloudFunction); - const result = await wrappedCF({ data: undefined }); - expect(result).equal('hello'); - - // @ts-expect-error request is required for untyped callables - () => wrappedCF(); + expect(await wrappedCF()).equal('hello'); + expect(await wrappedCF({ auth: { uid: 'test-user' } })).equal('hello'); }); it('should keep the callable request and return types', async () => { @@ -1451,10 +1449,16 @@ describe('v2', () => { // @ts-expect-error wrong data type () => wrappedCF({ data: { a: 'x' } }); - // @ts-expect-error request is required for typed callables - () => wrappedCF(); - // @ts-expect-error data is required for typed callables - () => wrappedCF({}); + }); + + it('should accept every CallableRequest field', () => { + type UnhandledKeys = Exclude< + keyof CallableRequest, + keyof WrappedV2CallableRequest + >; + const allKeysCovered: [UnhandledKeys] extends [never] ? true : false = + true; + expect(allKeysCovered).equal(true); }); it('should allow omitting the request when the callable takes no data', async () => { diff --git a/src/v2.ts b/src/v2.ts index db7cd066..5e297568 100644 --- a/src/v2.ts +++ b/src/v2.ts @@ -37,29 +37,21 @@ export type WrappedV2Function> = ( cloudEventPartial?: DeepPartial ) => any | Promise; -// `any` also matches `undefined | void`, so untyped callables are excluded explicitly. -type TakesNoData = 0 extends 1 & T - ? false - : [T] extends [undefined | void] - ? true - : false; - -type WrappedV2CallableRequest = (TakesNoData extends true - ? { data?: T } - : { data: T }) & { +export type WrappedV2CallableRequest = { + data?: T; rawRequest?: Partial; -} & DeepPartial, 'data' | 'rawRequest'>>>; - -type WrappedV2CallableArgs = TakesNoData extends true - ? [request?: WrappedV2CallableRequest] - : [request: WrappedV2CallableRequest]; + auth?: DeepPartial>; + app?: DeepPartial>; + instanceIdToken?: string; + acceptsStreaming?: boolean; +}; -/** A function that can be called with a {@link CallableRequest} for a v2 callable function. - * Every request field except `data` is optional, and the request itself can be omitted - * when the callable's data type is `undefined` or `void`. +/** + * A function that can be called with a {@link CallableRequest} for a v2 callable function. + * Every request field is optional to allow focused unit testing of auth, headers, or payloads. */ export type WrappedV2CallableFunction = ( - ...args: WrappedV2CallableArgs + request?: WrappedV2CallableRequest ) => Return; export function isCallableV2Function(cf: {