Skip to content

Commit

Permalink
Add tests for strongly typed function wrappers
Browse files Browse the repository at this point in the history
  • Loading branch information
garethgeorge committed Apr 7, 2023
1 parent 2501176 commit 4636946
Show file tree
Hide file tree
Showing 7 changed files with 384 additions and 47 deletions.
12 changes: 8 additions & 4 deletions src/function_registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
HandlerFunction,
TypedHandlerFunction,
InvocationFormat,
JsonInvocationSerializer,
JsonInvocationFormat,
} from './functions';
import {SignatureType} from './types';

Expand Down Expand Up @@ -75,7 +75,7 @@ export const isValidFunctionName = (functionName: string): boolean => {
export const getRegisteredFunction = (
functionName: string
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): RegisteredFunction<any> | undefined => {
): RegisteredFunction<any, any> | undefined => {
return registrationContainer.get(functionName);
};

Expand Down Expand Up @@ -103,15 +103,19 @@ export const cloudEvent = <T = unknown>(
};



/**
* Register a function that handles strongly typed invocations.
* @param functionName - the name of the function
* @param handler - the function to trigger.
*/
export const typed = <T, U>(
functionName: string,
handler: TypedHandlerFunction<T, U>['handler'] | TypedHandlerFunction<T, U>
): void => {
if (handler instanceof Function) {
handler = {
handler,
format: new JsonInvocationSerializer<T, U>(),
format: new JsonInvocationFormat<T, U>(),
}
}
register(functionName, 'typed', handler);
Expand Down
46 changes: 25 additions & 21 deletions src/function_wrappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import {
CloudEventFunctionWithCallback,
HandlerFunction,
TypedHandlerFunction,
InvocationFormat,
InvocationRequest,
InvocationResponse
} from './functions';
Expand Down Expand Up @@ -106,18 +105,25 @@ const parseBackgroundEvent = (req: Request): {data: {}; context: Context} => {
const wrapHttpFunction = (execute: HttpFunction): RequestHandler => {
return (req: Request, res: Response) => {
const d = domain.create();
// Catch unhandled errors originating from this request.
d.on('error', err => {
const errorHandler = (err: Error) => {
if (res.locals.functionExecutionFinished) {
console.error(`Exception from a finished function: ${err}`);
} else {
res.locals.functionExecutionFinished = true;
sendCrashResponse({err, res});
sendCrashResponse({ err, res });
}
});
};

// Catch unhandled errors originating from this request.
d.on('error', errorHandler);

d.run(() => {
process.nextTick(() => {
execute(req, res);
const ret = execute(req, res);
// Catch rejected promises if the function is async.
if (ret instanceof Promise) {
ret.catch(errorHandler);
}
});
});
};
Expand Down Expand Up @@ -216,45 +222,41 @@ const wrapTypedFunction = (
}

class InvocationResponseImpl implements InvocationResponse {
constructor(private req: Response) { }
constructor(private res: Response) { }

setHeader(key: string, value: string): void {
throw new Error('Method not implemented.');
this.res.set(key, value);
}
write(data: string | Buffer): void {
this.req.write(data);
this.res.write(data);
}
end(data: string | Buffer): void {
this.req.end(data);
this.res.end(data);
}
}

const handler: HttpFunction = async (req: Request, res: Response) => {
const typedHandlerWrapper: HttpFunction = async (req: Request, res: Response) => {
let reqTyped: any;
try {
reqTyped = typedFunction.format.deserializeRequest(new InvocationRequestImpl(req));
if (reqTyped instanceof Promise) {
reqTyped = await reqTyped;
}
} catch (err) {
console.log(err);
sendCrashResponse({
err, res,
statusOverride: 400 // 400 Bad Request
});
return;
}

let resTyped: any = typedFunction.handler(reqTyped);
if (resTyped instanceof Promise) {
resTyped = await resTyped;
}

const maybePromise = typedFunction.format.serializeResponse(new InvocationResponseImpl(res), resTyped);
if (maybePromise instanceof Promise) {
await maybePromise;
}
typedFunction.format.serializeResponse(new InvocationResponseImpl(res), resTyped);
}

return wrapHttpFunction(handler);
return wrapHttpFunction(typedHandlerWrapper);
}

/**
Expand All @@ -272,19 +274,21 @@ export const wrapUserFunction = <T = unknown>(
return wrapHttpFunction(userFunction as HttpFunction);
case 'event':
// Callback style if user function has more than 2 arguments.
if (userFunction!.length > 2) {
if (userFunction instanceof Function && userFunction!.length > 2) {
return wrapEventFunctionWithCallback(
userFunction as EventFunctionWithCallback
);
}
return wrapEventFunction(userFunction as EventFunction);
case 'cloudevent':
if (userFunction!.length > 1) {
if (userFunction instanceof Function && userFunction!.length > 1) {
// Callback style if user function has more than 1 argument.
return wrapCloudEventFunctionWithCallback(
userFunction as CloudEventFunctionWithCallback
);
}
return wrapCloudEventFunction(userFunction as CloudEventFunction);
case 'typed':
return wrapTypedFunction(userFunction as TypedHandlerFunction);
}
};
34 changes: 26 additions & 8 deletions src/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,9 @@ export type Context = CloudFunctionsContext | CloudEvent<unknown>;
* Invocation request interface describes the properties of an invocation.
*/
export interface InvocationRequest {
/** Returns the request body as either a string or a Buffer if the body is binary. */
body(): string | Buffer;
/** Header returns the value of the specified header */
header(header: string): string | undefined;
}

Expand All @@ -161,14 +163,20 @@ export interface InvocationRequest {
* an invocation response.
*/
export interface InvocationResponse {
/** Sets a header on the response. */
setHeader(key: string, value: string): void;
/** Writes a chunk of data to the response. */
write(data: string | Buffer): void;
/** Ends the response, must be called once at the end of writing. */
end(data: string | Buffer): void;
}

/**
* The contract for a request deserializer and response serializer.
*/
export interface InvocationFormat<T, U> {
/**
*
*
* @param request the request body as raw bytes
* @param headers the headers received on the HTTP request as a map
*/
Expand All @@ -178,20 +186,30 @@ export interface InvocationFormat<T, U> {
* @param response
* @param responseHeaders mutable object providing headers that will be set on the response
*/
serializeResponse(responseWriter: InvocationResponse, response: U): void | Promise<void>;
serializeResponse(
responseWriter: InvocationResponse,
response: U
): void | Promise<void>;
}

export class JsonInvocationSerializer<T, U> implements InvocationFormat<T, U> {
/**
* Default invocation format for JSON requests.
*/
export class JsonInvocationFormat<T, U> implements InvocationFormat<T, U> {
deserializeRequest(request: InvocationRequest): T {
if (!(typeof request.body === 'string')) {
throw new Error('Request Content-Type or encoding unsupported');
let body = request.body();
if (typeof body !== 'string') {
throw new Error("Unsupported Content-Type, expected application/json");
}
try {
return JSON.parse(body);
} catch (e) {
throw new Error("Failed to parse malformatted JSON in request: " + (e as SyntaxError).message)
}
if
return JSON.parse(request.body);
}

serializeResponse(responseWriter: InvocationResponse, response: U): void {
responseWriter.setHeader('content-type', 'application/json');
responseWriter.end(JSON.stringify(response));
}
}
}
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@ export * from './functions';
/**
* @public
*/
export {http, cloudEvent} from './function_registry';
export {http, cloudEvent, typed} from './function_registry';
17 changes: 14 additions & 3 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,20 @@ export function getServer(
};

// Apply middleware
app.use(bodyParser.json(cloudEventsBodySavingOptions));
app.use(bodyParser.json(defaultBodySavingOptions));
app.use(bodyParser.text(defaultBodySavingOptions));
if (functionSignatureType === 'typed') {
app.use(bodyParser.text({
limit: requestLimit,
type: '*/json',
}))
app.use(bodyParser.text({
limit: requestLimit,
type: 'text/*',
}))
} else {
app.use(bodyParser.json(cloudEventsBodySavingOptions));
app.use(bodyParser.json(defaultBodySavingOptions));
app.use(bodyParser.text(defaultBodySavingOptions));
}
app.use(bodyParser.urlencoded(urlEncodedOptions));
// The parser will process ALL content types so MUST come last.
// Subsequent parsers will be skipped when one is matched.
Expand Down
Loading

0 comments on commit 4636946

Please sign in to comment.