From 65f3f348b8dc323021272bac49d9d57ab36ec1dd Mon Sep 17 00:00:00 2001 From: Gareth Date: Wed, 28 Jun 2023 13:55:38 -0700 Subject: [PATCH] feat: introduce strongly typed function signature (#525) --- .github/workflows/conformance.yml | 14 +- docs/generated/api.json | 963 +++++++++++++++++++++++++++--- docs/generated/api.md | 40 +- src/function_registry.ts | 38 +- src/function_wrappers.ts | 96 ++- src/functions.ts | 90 ++- src/index.ts | 2 +- src/logger.ts | 6 +- src/server.ts | 13 +- src/types.ts | 2 +- test/conformance/function.js | 6 + test/function_registry.ts | 11 + test/function_wrappers.ts | 166 ++++- test/integration/typed.ts | 158 +++++ 14 files changed, 1494 insertions(+), 111 deletions(-) create mode 100644 test/integration/typed.ts diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 25509d4c..89dd9b9d 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -45,9 +45,9 @@ jobs: - name: Install conformance client uses: GoogleCloudPlatform/functions-framework-conformance/.github/actions/client/install@5f2a796b58f099d749e70ecc83f531f6701c64af # v1.8.3 with: - client-version: v1.7.0 + client-version: v1.8.3 cache-path: ~/client - cache-key: conformance-client-v1.7.0 + cache-key: conformance-client-v1.8.3 - name: Run HTTP conformance tests using legacy API working-directory: 'test/conformance' @@ -82,6 +82,16 @@ jobs: -validate-mapping=false \ -cmd="npm start -- --target=writeHttpDeclarative" + - name: Run Typed conformance tests using declarative API + working-directory: 'test/conformance' + run: | + ~/client \ + -type=http \ + -declarative-type=typed \ + -buildpacks=false \ + -validate-mapping=false \ + -cmd="npm start -- --target=writeTypedDeclarative" + - name: Run cloudevent conformance tests using declarative API working-directory: 'test/conformance' run: | diff --git a/docs/generated/api.json b/docs/generated/api.json index 0fe6eaa1..bfe8bdca 100644 --- a/docs/generated/api.json +++ b/docs/generated/api.json @@ -792,6 +792,14 @@ "kind": "Content", "text": "unknown" }, + { + "kind": "Content", + "text": ", U = " + }, + { + "kind": "Content", + "text": "unknown" + }, { "kind": "Content", "text": "> = " @@ -839,7 +847,16 @@ }, { "kind": "Content", - "text": "" + "text": " | " + }, + { + "kind": "Reference", + "text": "TypedFunction", + "canonicalReference": "@google-cloud/functions-framework!TypedFunction:interface" + }, + { + "kind": "Content", + "text": "" }, { "kind": "Content", @@ -860,11 +877,22 @@ "startIndex": 1, "endIndex": 2 } + }, + { + "typeParameterName": "U", + "constraintTokenRange": { + "startIndex": 0, + "endIndex": 0 + }, + "defaultTypeTokenRange": { + "startIndex": 3, + "endIndex": 4 + } } ], "typeTokenRange": { - "startIndex": 3, - "endIndex": 13 + "startIndex": 5, + "endIndex": 17 } }, { @@ -979,162 +1007,780 @@ "extendsTokenRanges": [] }, { - "kind": "TypeAlias", - "canonicalReference": "@google-cloud/functions-framework!LegacyCloudFunctionsContext:type", - "docComment": "/**\n * A legacy event function context.\n *\n * @public\n */\n", + "kind": "Interface", + "canonicalReference": "@google-cloud/functions-framework!InvocationFormat:interface", + "docComment": "/**\n * The contract for a request deserializer and response serializer.\n *\n * @public\n */\n", "excerptTokens": [ { "kind": "Content", - "text": "export type LegacyCloudFunctionsContext = " - }, + "text": "export interface InvocationFormat " + } + ], + "fileUrlPath": "src/functions.ts", + "releaseTag": "Public", + "typeParameters": [ { - "kind": "Reference", - "text": "CloudFunctionsContext", - "canonicalReference": "@google-cloud/functions-framework!CloudFunctionsContext:interface" + "typeParameterName": "T", + "constraintTokenRange": { + "startIndex": 0, + "endIndex": 0 + }, + "defaultTypeTokenRange": { + "startIndex": 0, + "endIndex": 0 + } }, { - "kind": "Content", - "text": " | " - }, + "typeParameterName": "U", + "constraintTokenRange": { + "startIndex": 0, + "endIndex": 0 + }, + "defaultTypeTokenRange": { + "startIndex": 0, + "endIndex": 0 + } + } + ], + "name": "InvocationFormat", + "preserveMemberOrder": false, + "members": [ { - "kind": "Reference", - "text": "Data", - "canonicalReference": "@google-cloud/functions-framework!Data:interface" + "kind": "MethodSignature", + "canonicalReference": "@google-cloud/functions-framework!InvocationFormat#deserializeRequest:member(1)", + "docComment": "/**\n * Creates an instance of the request type from an invocation request.\n *\n * @param request - the request body as raw bytes\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "deserializeRequest(request: " + }, + { + "kind": "Reference", + "text": "InvocationRequest", + "canonicalReference": "@google-cloud/functions-framework!InvocationRequest:interface" + }, + { + "kind": "Content", + "text": "): " + }, + { + "kind": "Content", + "text": "T | " + }, + { + "kind": "Reference", + "text": "Promise", + "canonicalReference": "!Promise:interface" + }, + { + "kind": "Content", + "text": "" + }, + { + "kind": "Content", + "text": ";" + } + ], + "isOptional": false, + "returnTypeTokenRange": { + "startIndex": 3, + "endIndex": 6 + }, + "releaseTag": "Public", + "overloadIndex": 1, + "parameters": [ + { + "parameterName": "request", + "parameterTypeTokenRange": { + "startIndex": 1, + "endIndex": 2 + }, + "isOptional": false + } + ], + "name": "deserializeRequest" }, { - "kind": "Content", - "text": ";" + "kind": "MethodSignature", + "canonicalReference": "@google-cloud/functions-framework!InvocationFormat#serializeResponse:member(1)", + "docComment": "/**\n * Writes the response type to the invocation result.\n *\n * @param responseWriter - interface for writing to the invocation result\n *\n * @param response - the response object\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "serializeResponse(responseWriter: " + }, + { + "kind": "Reference", + "text": "InvocationResponse", + "canonicalReference": "@google-cloud/functions-framework!InvocationResponse:interface" + }, + { + "kind": "Content", + "text": ", response: " + }, + { + "kind": "Content", + "text": "U" + }, + { + "kind": "Content", + "text": "): " + }, + { + "kind": "Content", + "text": "void | " + }, + { + "kind": "Reference", + "text": "Promise", + "canonicalReference": "!Promise:interface" + }, + { + "kind": "Content", + "text": "" + }, + { + "kind": "Content", + "text": ";" + } + ], + "isOptional": false, + "returnTypeTokenRange": { + "startIndex": 5, + "endIndex": 8 + }, + "releaseTag": "Public", + "overloadIndex": 1, + "parameters": [ + { + "parameterName": "responseWriter", + "parameterTypeTokenRange": { + "startIndex": 1, + "endIndex": 2 + }, + "isOptional": false + }, + { + "parameterName": "response", + "parameterTypeTokenRange": { + "startIndex": 3, + "endIndex": 4 + }, + "isOptional": false + } + ], + "name": "serializeResponse" } ], - "fileUrlPath": "src/functions.ts", - "releaseTag": "Public", - "name": "LegacyCloudFunctionsContext", - "typeTokenRange": { - "startIndex": 1, - "endIndex": 4 - } + "extendsTokenRanges": [] }, { "kind": "Interface", - "canonicalReference": "@google-cloud/functions-framework!LegacyEvent:interface", - "docComment": "/**\n * A legacy event.\n *\n * @public\n */\n", + "canonicalReference": "@google-cloud/functions-framework!InvocationRequest:interface", + "docComment": "/**\n * InvocationRequest represents the properties of an invocation over HTTP.\n *\n * @public\n */\n", "excerptTokens": [ { "kind": "Content", - "text": "export interface LegacyEvent " + "text": "export interface InvocationRequest " } ], "fileUrlPath": "src/functions.ts", "releaseTag": "Public", - "name": "LegacyEvent", + "name": "InvocationRequest", "preserveMemberOrder": false, "members": [ { - "kind": "PropertySignature", - "canonicalReference": "@google-cloud/functions-framework!LegacyEvent#context:member", - "docComment": "", + "kind": "MethodSignature", + "canonicalReference": "@google-cloud/functions-framework!InvocationRequest#body:member(1)", + "docComment": "/**\n * Returns the request body as either a string or a Buffer if the body is binary.\n */\n", "excerptTokens": [ { "kind": "Content", - "text": "context: " + "text": "body(): " + }, + { + "kind": "Content", + "text": "string | " }, { "kind": "Reference", - "text": "CloudFunctionsContext", - "canonicalReference": "@google-cloud/functions-framework!CloudFunctionsContext:interface" + "text": "Buffer", + "canonicalReference": "!Buffer:class" }, { "kind": "Content", "text": ";" } ], - "isReadonly": false, "isOptional": false, - "releaseTag": "Public", - "name": "context", - "propertyTypeTokenRange": { + "returnTypeTokenRange": { "startIndex": 1, - "endIndex": 2 - } + "endIndex": 3 + }, + "releaseTag": "Public", + "overloadIndex": 1, + "parameters": [], + "name": "body" }, { - "kind": "PropertySignature", - "canonicalReference": "@google-cloud/functions-framework!LegacyEvent#data:member", - "docComment": "", + "kind": "MethodSignature", + "canonicalReference": "@google-cloud/functions-framework!InvocationRequest#header:member(1)", + "docComment": "/**\n * Header returns the value of the specified header\n */\n", "excerptTokens": [ { "kind": "Content", - "text": "data: " + "text": "header(header: " }, { "kind": "Content", - "text": "{\n [key: string]: any;\n }" + "text": "string" + }, + { + "kind": "Content", + "text": "): " + }, + { + "kind": "Content", + "text": "string | undefined" }, { "kind": "Content", "text": ";" } ], - "isReadonly": false, "isOptional": false, + "returnTypeTokenRange": { + "startIndex": 3, + "endIndex": 4 + }, "releaseTag": "Public", - "name": "data", - "propertyTypeTokenRange": { - "startIndex": 1, - "endIndex": 2 - } + "overloadIndex": 1, + "parameters": [ + { + "parameterName": "header", + "parameterTypeTokenRange": { + "startIndex": 1, + "endIndex": 2 + }, + "isOptional": false + } + ], + "name": "header" } ], "extendsTokenRanges": [] }, { "kind": "Interface", - "canonicalReference": "@google-cloud/functions-framework!Request_2:interface", - "docComment": "/**\n * @public\n */\n", + "canonicalReference": "@google-cloud/functions-framework!InvocationResponse:interface", + "docComment": "/**\n * InvocationResponse interface describes the properties that can be set on an invocation response.\n *\n * @public\n */\n", "excerptTokens": [ { "kind": "Content", - "text": "export interface Request extends " - }, - { - "kind": "Reference", - "text": "ExpressRequest", - "canonicalReference": "@types/express!e.Request:interface" - }, - { - "kind": "Content", - "text": " " + "text": "export interface InvocationResponse " } ], "fileUrlPath": "src/functions.ts", "releaseTag": "Public", - "name": "Request_2", + "name": "InvocationResponse", "preserveMemberOrder": false, "members": [ { - "kind": "PropertySignature", - "canonicalReference": "@google-cloud/functions-framework!Request_2#rawBody:member", - "docComment": "/**\n * A buffer which provides access to the request's raw HTTP body.\n */\n", + "kind": "MethodSignature", + "canonicalReference": "@google-cloud/functions-framework!InvocationResponse#end:member(1)", + "docComment": "/**\n * Ends the response, must be called once at the end of writing.\n */\n", "excerptTokens": [ { "kind": "Content", - "text": "rawBody?: " + "text": "end(data: " + }, + { + "kind": "Content", + "text": "string | " }, { "kind": "Reference", "text": "Buffer", "canonicalReference": "!Buffer:class" }, + { + "kind": "Content", + "text": "): " + }, + { + "kind": "Content", + "text": "void" + }, { "kind": "Content", "text": ";" } ], - "isReadonly": false, - "isOptional": true, - "releaseTag": "Public", - "name": "rawBody", - "propertyTypeTokenRange": { + "isOptional": false, + "returnTypeTokenRange": { + "startIndex": 4, + "endIndex": 5 + }, + "releaseTag": "Public", + "overloadIndex": 1, + "parameters": [ + { + "parameterName": "data", + "parameterTypeTokenRange": { + "startIndex": 1, + "endIndex": 3 + }, + "isOptional": false + } + ], + "name": "end" + }, + { + "kind": "MethodSignature", + "canonicalReference": "@google-cloud/functions-framework!InvocationResponse#setHeader:member(1)", + "docComment": "/**\n * Sets a header on the response.\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "setHeader(key: " + }, + { + "kind": "Content", + "text": "string" + }, + { + "kind": "Content", + "text": ", value: " + }, + { + "kind": "Content", + "text": "string" + }, + { + "kind": "Content", + "text": "): " + }, + { + "kind": "Content", + "text": "void" + }, + { + "kind": "Content", + "text": ";" + } + ], + "isOptional": false, + "returnTypeTokenRange": { + "startIndex": 5, + "endIndex": 6 + }, + "releaseTag": "Public", + "overloadIndex": 1, + "parameters": [ + { + "parameterName": "key", + "parameterTypeTokenRange": { + "startIndex": 1, + "endIndex": 2 + }, + "isOptional": false + }, + { + "parameterName": "value", + "parameterTypeTokenRange": { + "startIndex": 3, + "endIndex": 4 + }, + "isOptional": false + } + ], + "name": "setHeader" + }, + { + "kind": "MethodSignature", + "canonicalReference": "@google-cloud/functions-framework!InvocationResponse#write:member(1)", + "docComment": "/**\n * Writes a chunk of data to the response.\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "write(data: " + }, + { + "kind": "Content", + "text": "string | " + }, + { + "kind": "Reference", + "text": "Buffer", + "canonicalReference": "!Buffer:class" + }, + { + "kind": "Content", + "text": "): " + }, + { + "kind": "Content", + "text": "void" + }, + { + "kind": "Content", + "text": ";" + } + ], + "isOptional": false, + "returnTypeTokenRange": { + "startIndex": 4, + "endIndex": 5 + }, + "releaseTag": "Public", + "overloadIndex": 1, + "parameters": [ + { + "parameterName": "data", + "parameterTypeTokenRange": { + "startIndex": 1, + "endIndex": 3 + }, + "isOptional": false + } + ], + "name": "write" + } + ], + "extendsTokenRanges": [] + }, + { + "kind": "Class", + "canonicalReference": "@google-cloud/functions-framework!JsonInvocationFormat:class", + "docComment": "/**\n * Default invocation format for JSON requests.\n *\n * @public\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "export declare class JsonInvocationFormat implements " + }, + { + "kind": "Reference", + "text": "InvocationFormat", + "canonicalReference": "@google-cloud/functions-framework!InvocationFormat:interface" + }, + { + "kind": "Content", + "text": "" + }, + { + "kind": "Content", + "text": " " + } + ], + "fileUrlPath": "src/functions.ts", + "releaseTag": "Public", + "typeParameters": [ + { + "typeParameterName": "T", + "constraintTokenRange": { + "startIndex": 0, + "endIndex": 0 + }, + "defaultTypeTokenRange": { + "startIndex": 0, + "endIndex": 0 + } + }, + { + "typeParameterName": "U", + "constraintTokenRange": { + "startIndex": 0, + "endIndex": 0 + }, + "defaultTypeTokenRange": { + "startIndex": 0, + "endIndex": 0 + } + } + ], + "isAbstract": false, + "name": "JsonInvocationFormat", + "preserveMemberOrder": false, + "members": [ + { + "kind": "Method", + "canonicalReference": "@google-cloud/functions-framework!JsonInvocationFormat#deserializeRequest:member(1)", + "docComment": "", + "excerptTokens": [ + { + "kind": "Content", + "text": "deserializeRequest(request: " + }, + { + "kind": "Reference", + "text": "InvocationRequest", + "canonicalReference": "@google-cloud/functions-framework!InvocationRequest:interface" + }, + { + "kind": "Content", + "text": "): " + }, + { + "kind": "Content", + "text": "T" + }, + { + "kind": "Content", + "text": ";" + } + ], + "isStatic": false, + "returnTypeTokenRange": { + "startIndex": 3, + "endIndex": 4 + }, + "releaseTag": "Public", + "isProtected": false, + "overloadIndex": 1, + "parameters": [ + { + "parameterName": "request", + "parameterTypeTokenRange": { + "startIndex": 1, + "endIndex": 2 + }, + "isOptional": false + } + ], + "isOptional": false, + "isAbstract": false, + "name": "deserializeRequest" + }, + { + "kind": "Method", + "canonicalReference": "@google-cloud/functions-framework!JsonInvocationFormat#serializeResponse:member(1)", + "docComment": "", + "excerptTokens": [ + { + "kind": "Content", + "text": "serializeResponse(responseWriter: " + }, + { + "kind": "Reference", + "text": "InvocationResponse", + "canonicalReference": "@google-cloud/functions-framework!InvocationResponse:interface" + }, + { + "kind": "Content", + "text": ", response: " + }, + { + "kind": "Content", + "text": "U" + }, + { + "kind": "Content", + "text": "): " + }, + { + "kind": "Content", + "text": "void" + }, + { + "kind": "Content", + "text": ";" + } + ], + "isStatic": false, + "returnTypeTokenRange": { + "startIndex": 5, + "endIndex": 6 + }, + "releaseTag": "Public", + "isProtected": false, + "overloadIndex": 1, + "parameters": [ + { + "parameterName": "responseWriter", + "parameterTypeTokenRange": { + "startIndex": 1, + "endIndex": 2 + }, + "isOptional": false + }, + { + "parameterName": "response", + "parameterTypeTokenRange": { + "startIndex": 3, + "endIndex": 4 + }, + "isOptional": false + } + ], + "isOptional": false, + "isAbstract": false, + "name": "serializeResponse" + } + ], + "implementsTokenRanges": [ + { + "startIndex": 1, + "endIndex": 3 + } + ] + }, + { + "kind": "TypeAlias", + "canonicalReference": "@google-cloud/functions-framework!LegacyCloudFunctionsContext:type", + "docComment": "/**\n * A legacy event function context.\n *\n * @public\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "export type LegacyCloudFunctionsContext = " + }, + { + "kind": "Reference", + "text": "CloudFunctionsContext", + "canonicalReference": "@google-cloud/functions-framework!CloudFunctionsContext:interface" + }, + { + "kind": "Content", + "text": " | " + }, + { + "kind": "Reference", + "text": "Data", + "canonicalReference": "@google-cloud/functions-framework!Data:interface" + }, + { + "kind": "Content", + "text": ";" + } + ], + "fileUrlPath": "src/functions.ts", + "releaseTag": "Public", + "name": "LegacyCloudFunctionsContext", + "typeTokenRange": { + "startIndex": 1, + "endIndex": 4 + } + }, + { + "kind": "Interface", + "canonicalReference": "@google-cloud/functions-framework!LegacyEvent:interface", + "docComment": "/**\n * A legacy event.\n *\n * @public\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "export interface LegacyEvent " + } + ], + "fileUrlPath": "src/functions.ts", + "releaseTag": "Public", + "name": "LegacyEvent", + "preserveMemberOrder": false, + "members": [ + { + "kind": "PropertySignature", + "canonicalReference": "@google-cloud/functions-framework!LegacyEvent#context:member", + "docComment": "", + "excerptTokens": [ + { + "kind": "Content", + "text": "context: " + }, + { + "kind": "Reference", + "text": "CloudFunctionsContext", + "canonicalReference": "@google-cloud/functions-framework!CloudFunctionsContext:interface" + }, + { + "kind": "Content", + "text": ";" + } + ], + "isReadonly": false, + "isOptional": false, + "releaseTag": "Public", + "name": "context", + "propertyTypeTokenRange": { + "startIndex": 1, + "endIndex": 2 + } + }, + { + "kind": "PropertySignature", + "canonicalReference": "@google-cloud/functions-framework!LegacyEvent#data:member", + "docComment": "", + "excerptTokens": [ + { + "kind": "Content", + "text": "data: " + }, + { + "kind": "Content", + "text": "{\n [key: string]: any;\n }" + }, + { + "kind": "Content", + "text": ";" + } + ], + "isReadonly": false, + "isOptional": false, + "releaseTag": "Public", + "name": "data", + "propertyTypeTokenRange": { + "startIndex": 1, + "endIndex": 2 + } + } + ], + "extendsTokenRanges": [] + }, + { + "kind": "Interface", + "canonicalReference": "@google-cloud/functions-framework!Request_2:interface", + "docComment": "/**\n * @public\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "export interface Request extends " + }, + { + "kind": "Reference", + "text": "ExpressRequest", + "canonicalReference": "@types/express!e.Request:interface" + }, + { + "kind": "Content", + "text": " " + } + ], + "fileUrlPath": "src/functions.ts", + "releaseTag": "Public", + "name": "Request_2", + "preserveMemberOrder": false, + "members": [ + { + "kind": "PropertySignature", + "canonicalReference": "@google-cloud/functions-framework!Request_2#rawBody:member", + "docComment": "/**\n * A buffer which provides access to the request's raw HTTP body.\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "rawBody?: " + }, + { + "kind": "Reference", + "text": "Buffer", + "canonicalReference": "!Buffer:class" + }, + { + "kind": "Content", + "text": ";" + } + ], + "isReadonly": false, + "isOptional": true, + "releaseTag": "Public", + "name": "rawBody", + "propertyTypeTokenRange": { "startIndex": 1, "endIndex": 2 } @@ -1146,6 +1792,173 @@ "endIndex": 2 } ] + }, + { + "kind": "Variable", + "canonicalReference": "@google-cloud/functions-framework!typed:var", + "docComment": "/**\n * Register a function that handles strongly typed invocations.\n *\n * @param functionName - the name of the function\n *\n * @param handler - the function to trigger\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "typed: " + }, + { + "kind": "Content", + "text": "(functionName: string, handler: " + }, + { + "kind": "Reference", + "text": "TypedFunction", + "canonicalReference": "@google-cloud/functions-framework!TypedFunction:interface" + }, + { + "kind": "Content", + "text": " | ((req: T) => U | " + }, + { + "kind": "Reference", + "text": "Promise", + "canonicalReference": "!Promise:interface" + }, + { + "kind": "Content", + "text": ")) => void" + } + ], + "fileUrlPath": "src/function_registry.ts", + "isReadonly": true, + "releaseTag": "Public", + "name": "typed", + "variableTypeTokenRange": { + "startIndex": 1, + "endIndex": 6 + } + }, + { + "kind": "Interface", + "canonicalReference": "@google-cloud/functions-framework!TypedFunction:interface", + "docComment": "/**\n * A Typed function handler that may return a value or a promise.\n *\n * @public\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "export interface TypedFunction " + } + ], + "fileUrlPath": "src/functions.ts", + "releaseTag": "Public", + "typeParameters": [ + { + "typeParameterName": "T", + "constraintTokenRange": { + "startIndex": 0, + "endIndex": 0 + }, + "defaultTypeTokenRange": { + "startIndex": 1, + "endIndex": 2 + } + }, + { + "typeParameterName": "U", + "constraintTokenRange": { + "startIndex": 0, + "endIndex": 0 + }, + "defaultTypeTokenRange": { + "startIndex": 3, + "endIndex": 4 + } + } + ], + "name": "TypedFunction", + "preserveMemberOrder": false, + "members": [ + { + "kind": "PropertySignature", + "canonicalReference": "@google-cloud/functions-framework!TypedFunction#format:member", + "docComment": "", + "excerptTokens": [ + { + "kind": "Content", + "text": "format: " + }, + { + "kind": "Reference", + "text": "InvocationFormat", + "canonicalReference": "@google-cloud/functions-framework!InvocationFormat:interface" + }, + { + "kind": "Content", + "text": "" + }, + { + "kind": "Content", + "text": ";" + } + ], + "isReadonly": false, + "isOptional": false, + "releaseTag": "Public", + "name": "format", + "propertyTypeTokenRange": { + "startIndex": 1, + "endIndex": 3 + } + }, + { + "kind": "PropertySignature", + "canonicalReference": "@google-cloud/functions-framework!TypedFunction#handler:member", + "docComment": "", + "excerptTokens": [ + { + "kind": "Content", + "text": "handler: " + }, + { + "kind": "Content", + "text": "(req: T) => U | " + }, + { + "kind": "Reference", + "text": "Promise", + "canonicalReference": "!Promise:interface" + }, + { + "kind": "Content", + "text": "" + }, + { + "kind": "Content", + "text": ";" + } + ], + "isReadonly": false, + "isOptional": false, + "releaseTag": "Public", + "name": "handler", + "propertyTypeTokenRange": { + "startIndex": 1, + "endIndex": 4 + } + } + ], + "extendsTokenRanges": [] } ] } diff --git a/docs/generated/api.md b/docs/generated/api.md index c2fa63d7..5868b4e9 100644 --- a/docs/generated/api.md +++ b/docs/generated/api.md @@ -59,7 +59,7 @@ export interface EventFunctionWithCallback { } // @public -export type HandlerFunction = HttpFunction | EventFunction | EventFunctionWithCallback | CloudEventFunction | CloudEventFunctionWithCallback; +export type HandlerFunction = HttpFunction | EventFunction | EventFunctionWithCallback | CloudEventFunction | CloudEventFunctionWithCallback | TypedFunction; // @public export const http: (functionName: string, handler: HttpFunction) => void; @@ -70,6 +70,33 @@ export interface HttpFunction { (req: Request_2, res: Response_2): any; } +// @public +export interface InvocationFormat { + deserializeRequest(request: InvocationRequest): T | Promise; + serializeResponse(responseWriter: InvocationResponse, response: U): void | Promise; +} + +// @public +export interface InvocationRequest { + body(): string | Buffer; + header(header: string): string | undefined; +} + +// @public +export interface InvocationResponse { + end(data: string | Buffer): void; + setHeader(key: string, value: string): void; + write(data: string | Buffer): void; +} + +// @public +export class JsonInvocationFormat implements InvocationFormat { + // (undocumented) + deserializeRequest(request: InvocationRequest): T; + // (undocumented) + serializeResponse(responseWriter: InvocationResponse, response: U): void; +} + // @public export type LegacyCloudFunctionsContext = CloudFunctionsContext | Data; @@ -91,6 +118,17 @@ export { Request_2 as Request } export { Response_2 as Response } +// @public +export const typed: (functionName: string, handler: TypedFunction | ((req: T) => U | Promise)) => void; + +// @public +export interface TypedFunction { + // (undocumented) + format: InvocationFormat; + // (undocumented) + handler: (req: T) => U | Promise; +} + // (No @packageDocumentation comment for this package) ``` diff --git a/src/function_registry.ts b/src/function_registry.ts index 15b38ce3..60261d6c 100644 --- a/src/function_registry.ts +++ b/src/function_registry.ts @@ -12,27 +12,33 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {HttpFunction, CloudEventFunction, HandlerFunction} from './functions'; +import { + HttpFunction, + CloudEventFunction, + HandlerFunction, + TypedFunction, + JsonInvocationFormat, +} from './functions'; import {SignatureType} from './types'; -interface RegisteredFunction { +interface RegisteredFunction { signatureType: SignatureType; - userFunction: HandlerFunction; + userFunction: HandlerFunction; } /** * Singleton map to hold the registered functions */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -const registrationContainer = new Map>(); +const registrationContainer = new Map>(); /** * Helper method to store a registered function in the registration container */ -const register = ( +const register = ( functionName: string, signatureType: SignatureType, - userFunction: HandlerFunction + userFunction: HandlerFunction ): void => { if (!isValidFunctionName(functionName)) { throw new Error(`Invalid function name: ${functionName}`); @@ -68,7 +74,7 @@ export const isValidFunctionName = (functionName: string): boolean => { export const getRegisteredFunction = ( functionName: string // eslint-disable-next-line @typescript-eslint/no-explicit-any -): RegisteredFunction | undefined => { +): RegisteredFunction | undefined => { return registrationContainer.get(functionName); }; @@ -94,3 +100,21 @@ export const cloudEvent = ( ): void => { register(functionName, 'cloudevent', handler); }; + +/** + * Register a function that handles strongly typed invocations. + * @param functionName - the name of the function + * @param handler - the function to trigger + */ +export const typed = ( + functionName: string, + handler: TypedFunction['handler'] | TypedFunction +): void => { + if (handler instanceof Function) { + handler = { + handler, + format: new JsonInvocationFormat(), + }; + } + register(functionName, 'typed', handler); +}; diff --git a/src/function_wrappers.ts b/src/function_wrappers.ts index 8393d171..b497f196 100644 --- a/src/function_wrappers.ts +++ b/src/function_wrappers.ts @@ -26,6 +26,9 @@ import { CloudEventFunction, CloudEventFunctionWithCallback, HandlerFunction, + TypedFunction, + InvocationRequest, + InvocationResponse, } from './functions'; import {CloudEvent} from './functions'; import {SignatureType} from './types'; @@ -102,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}); } - }); + }; + + // 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); + } }); }); }; @@ -176,7 +186,7 @@ const wrapEventFunction = (userFunction: EventFunction): RequestHandler => { }; /** - * Wraps an callback style event function in an express RequestHandler. + * Wraps a callback style event function in an express RequestHandler. * @param userFunction User's function. * @return An Express hander function that invokes the user function. */ @@ -191,6 +201,44 @@ const wrapEventFunctionWithCallback = ( return wrapHttpFunction(httpHandler); }; +/** + * Wraps a typed function in an express style RequestHandler. + * @param userFunction User's function + * @return An Express handler function that invokes the user function + */ +const wrapTypedFunction = (typedFunction: TypedFunction): RequestHandler => { + const typedHandlerWrapper: HttpFunction = async ( + req: Request, + res: Response + ) => { + let reqTyped: unknown; + try { + reqTyped = typedFunction.format.deserializeRequest( + new InvocationRequestImpl(req) + ); + } catch (err) { + sendCrashResponse({ + err, + res, + statusOverride: 400, // 400 Bad Request + }); + return; + } + + let resTyped: unknown = typedFunction.handler(reqTyped); + if (resTyped instanceof Promise) { + resTyped = await resTyped; + } + + typedFunction.format.serializeResponse( + new InvocationResponseImpl(res), + resTyped + ); + }; + + return wrapHttpFunction(typedHandlerWrapper); +}; + /** * Wraps a user function with the provided signature type in an express * RequestHandler. @@ -206,19 +254,53 @@ export const wrapUserFunction = ( 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 TypedFunction); } }; + +/** + * @private + */ +class InvocationRequestImpl implements InvocationRequest { + constructor(private req: Request) {} + + body(): string | Buffer { + return this.req.body; + } + + header(header: string): string | undefined { + return this.req.header(header); + } +} + +/** + * @private + */ +class InvocationResponseImpl implements InvocationResponse { + constructor(private res: Response) {} + + setHeader(key: string, value: string): void { + this.res.set(key, value); + } + write(data: string | Buffer): void { + this.res.write(data); + } + end(data: string | Buffer): void { + this.res.end(data); + } +} diff --git a/src/functions.ts b/src/functions.ts index 50b1adad..ae7a908c 100644 --- a/src/functions.ts +++ b/src/functions.ts @@ -74,16 +74,27 @@ export interface CloudEventFunction { export interface CloudEventFunctionWithCallback { (cloudEvent: CloudEvent, callback: Function): any; } + +/** + * A Typed function handler that may return a value or a promise. + * @public + */ +export interface TypedFunction { + handler: (req: T) => U | Promise; + format: InvocationFormat; +} + /** * A function handler. * @public */ -export type HandlerFunction = +export type HandlerFunction = | HttpFunction | EventFunction | EventFunctionWithCallback | CloudEventFunction - | CloudEventFunctionWithCallback; + | CloudEventFunctionWithCallback + | TypedFunction; /** * A legacy event. @@ -137,3 +148,78 @@ export interface CloudFunctionsContext { * @public */ export type Context = CloudFunctionsContext | CloudEvent; + +/** + * InvocationRequest represents the properties of an invocation over HTTP. + * @public + */ +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; +} + +/** + * InvocationResponse interface describes the properties that can be set on + * an invocation response. + * @public + */ +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. + * @public + */ +export interface InvocationFormat { + /** + * Creates an instance of the request type from an invocation request. + * + * @param request the request body as raw bytes + */ + deserializeRequest(request: InvocationRequest): T | Promise; + + /** + * Writes the response type to the invocation result. + * + * @param responseWriter interface for writing to the invocation result + * @param response the response object + */ + serializeResponse( + responseWriter: InvocationResponse, + response: U + ): void | Promise; +} + +/** + * Default invocation format for JSON requests. + * @public + */ +export class JsonInvocationFormat implements InvocationFormat { + deserializeRequest(request: InvocationRequest): T { + const 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 + ); + } + } + + serializeResponse(responseWriter: InvocationResponse, response: U): void { + responseWriter.setHeader('content-type', 'application/json'); + responseWriter.end(JSON.stringify(response)); + } +} diff --git a/src/index.ts b/src/index.ts index 9c804b63..ba72954f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,4 +20,4 @@ export * from './functions'; /** * @public */ -export {http, cloudEvent} from './function_registry'; +export {http, cloudEvent, typed} from './function_registry'; diff --git a/src/logger.ts b/src/logger.ts index 57c7d3bf..8b824736 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -28,6 +28,7 @@ export function sendCrashResponse({ callback, silent = false, statusHeader = 'crash', + statusOverride = 500, }: { // eslint-disable-next-line @typescript-eslint/no-explicit-any err: Error | any; @@ -35,6 +36,7 @@ export function sendCrashResponse({ callback?: Function; silent?: boolean; statusHeader?: string; + statusOverride?: number; }) { if (!silent) { console.error(err.stack || err); @@ -48,10 +50,10 @@ export function sendCrashResponse({ res.set(FUNCTION_STATUS_HEADER_FIELD, statusHeader); if (process.env.NODE_ENV !== 'production') { - res.status(500); + res.status(statusOverride); res.send((err.message || err) + ''); } else { - res.sendStatus(500); + res.sendStatus(statusOverride); } } if (callback) { diff --git a/src/server.ts b/src/server.ts index 1afc3349..399becc1 100644 --- a/src/server.ts +++ b/src/server.ts @@ -85,8 +85,17 @@ export function getServer( }; // Apply middleware - app.use(bodyParser.json(cloudEventsBodySavingOptions)); - app.use(bodyParser.json(defaultBodySavingOptions)); + if (functionSignatureType !== 'typed') { + // If the function is not typed then JSON parsing can be done automatically, otherwise the + // functions format must determine deserialization. + app.use(bodyParser.json(cloudEventsBodySavingOptions)); + app.use(bodyParser.json(defaultBodySavingOptions)); + } else { + const jsonParserOptions = Object.assign({}, defaultBodySavingOptions, { + type: 'application/json', + }); + app.use(bodyParser.text(jsonParserOptions)); + } app.use(bodyParser.text(defaultBodySavingOptions)); app.use(bodyParser.urlencoded(urlEncodedOptions)); // The parser will process ALL content types so MUST come last. diff --git a/src/types.ts b/src/types.ts index 7d2d2f5b..4838f7ed 100644 --- a/src/types.ts +++ b/src/types.ts @@ -19,7 +19,7 @@ export const FUNCTION_STATUS_HEADER_FIELD = 'X-Google-Status'; /** * List of function signature types that are supported by the framework. */ -export const SignatureType = ['http', 'event', 'cloudevent'] as const; +export const SignatureType = ['http', 'event', 'cloudevent', 'typed'] as const; /** * Union type of all valid function SignatureType values. diff --git a/test/conformance/function.js b/test/conformance/function.js index 78181c07..03d2d88c 100644 --- a/test/conformance/function.js +++ b/test/conformance/function.js @@ -8,6 +8,12 @@ functions.http('writeHttpDeclarative', (req, res) => { res.sendStatus(200); }); +functions.typed('writeTypedDeclarative', req => { + return { + payload: req, + }; +}); + functions.cloudEvent('writeCloudEventDeclarative', cloudEvent => { cloudEvent.datacontenttype = 'application/json'; writeJson(cloudEvent); diff --git a/test/function_registry.ts b/test/function_registry.ts index fe26d4e3..e111036c 100644 --- a/test/function_registry.ts +++ b/test/function_registry.ts @@ -13,6 +13,7 @@ // limitations under the License. import * as assert from 'assert'; import * as FunctionRegistry from '../src/function_registry'; +import {JsonInvocationFormat} from '../src'; describe('function_registry', () => { it('can register http functions', () => { @@ -31,6 +32,16 @@ describe('function_registry', () => { assert.deepStrictEqual((userFunction as () => string)(), 'CE_PASS'); }); + it('can register typed functions', () => { + FunctionRegistry.typed('typedFunction', (identity: string) => identity); + const {userFunction, signatureType} = + FunctionRegistry.getRegisteredFunction('typedFunction')!; + assert.deepStrictEqual('typed', signatureType); + + assert.ok(!(userFunction instanceof Function)); + assert.ok(userFunction.format instanceof JsonInvocationFormat); + }); + it('throws an error if you try to register a function with an invalid URL', () => { // Valid function names const validFunctions = ['httpFunction', 'ceFunction', 'test-func']; diff --git a/test/function_wrappers.ts b/test/function_wrappers.ts index 56dca187..32634db8 100644 --- a/test/function_wrappers.ts +++ b/test/function_wrappers.ts @@ -1,8 +1,13 @@ import * as assert from 'assert'; -import * as sinon from 'sinon'; import {Request, Response} from 'express'; -import {Context, CloudEvent} from '../src/functions'; +import { + Context, + CloudEvent, + JsonInvocationFormat, + TypedFunction, +} from '../src/functions'; import {wrapUserFunction} from '../src/function_wrappers'; +import EventEmitter = require('events'); describe('wrapUserFunction', () => { const CLOUD_EVENT = { @@ -17,20 +22,55 @@ describe('wrapUserFunction', () => { }, }; - const createRequest = (body: object) => + const createRequest = ( + body: object | string, + headers?: {[key: string]: string} + ) => ({ body, // eslint-disable-next-line @typescript-eslint/no-unused-vars - header: (_: string) => '', + header: (h: string) => { + return headers === undefined ? '' : headers[h]; + }, } as Request); - const createResponse = () => - ({ - locals: { - functionExecutionFinished: false, - }, - sendStatus: sinon.spy(), - } as unknown as Response); + class ResponseMock extends EventEmitter { + public headers: {[key: string]: string} = {}; + public statusCode = 200; + public body: string | undefined; + public locals = { + functionExecutionFinished: false, + }; + + set(header: string, value: string) { + this.headers[header.toLowerCase()] = value; + } + + status(status: number) { + this.statusCode = status; + } + + sendStatus(status: number) { + this.status(status); + } + + end(body: string) { + this.body = body; + this.emit('done'); + } + + send(body: string) { + this.end(body); + } + } + + const createResponse = () => { + return new ResponseMock() as unknown as Response; + }; + + interface EchoMessage { + message: string; + } it('correctly wraps an http function', done => { const request = createRequest({foo: 'bar'}); @@ -105,4 +145,108 @@ describe('wrapUserFunction', () => { ); func(request, response, () => {}); }); + + describe('wraps a Typed JSON function', () => { + const synchronousJsonFunction: TypedFunction = { + format: new JsonInvocationFormat(), + handler: (req: EchoMessage): EchoMessage => { + return { + message: req.message, + }; + }, + }; + it('when the handler is synchronous', done => { + const payload = JSON.stringify({ + message: 'test', + }); + + const request = createRequest(payload); + const response = new ResponseMock(); + const func = wrapUserFunction(synchronousJsonFunction, 'typed'); + + func(request, response as unknown as Response, () => {}); + + response.on('done', () => { + assert.strictEqual(response.statusCode, 200); + assert.strictEqual( + response.headers['content-type'], + 'application/json' + ); + assert.strictEqual(response.body, payload); + done(); + }); + }); + + it('when the format throws a parse error', done => { + const payload = 'Asdf'; + + const request = createRequest(payload); + const response = new ResponseMock(); + const func = wrapUserFunction(synchronousJsonFunction, 'typed'); + + func(request, response as unknown as Response, () => {}); + + response.on('done', () => { + assert.strictEqual(response.statusCode, 400); + done(); + }); + }); + + it('when the handler is asynchronous', done => { + const payload = JSON.stringify({ + message: 'test', + }); + + const typedFn: TypedFunction = { + format: new JsonInvocationFormat(), + handler: (req: EchoMessage): Promise => { + return new Promise(accept => { + setImmediate(() => accept(req)); + }); + }, + }; + + const request = createRequest(payload); + const response = new ResponseMock(); + const func = wrapUserFunction(typedFn, 'typed'); + + func(request, response as unknown as Response, () => {}); + + response.on('done', () => { + assert.strictEqual(response.statusCode, 200); + assert.strictEqual( + response.headers['content-type'], + 'application/json' + ); + assert.strictEqual(response.body, payload); + done(); + }); + }); + + it('when the async handler throws an error', done => { + const payload = JSON.stringify({ + message: 'test', + }); + + const typedFn: TypedFunction = { + format: new JsonInvocationFormat(), + handler: (): Promise => { + return new Promise((_, reject) => { + setImmediate(() => reject(new Error('an error'))); + }); + }, + }; + + const request = createRequest(payload); + const response = new ResponseMock(); + const func = wrapUserFunction(typedFn, 'typed'); + + func(request, response as unknown as Response, () => {}); + + response.on('done', () => { + assert.strictEqual(response.statusCode, 500); + done(); + }); + }); + }); }); diff --git a/test/integration/typed.ts b/test/integration/typed.ts new file mode 100644 index 00000000..ed38a915 --- /dev/null +++ b/test/integration/typed.ts @@ -0,0 +1,158 @@ +// Copyright 2019 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import * as supertest from 'supertest'; + +import * as functions from '../../src/index'; +import {getTestServer} from '../../src/testing'; + +describe('Typed Function', () => { + let callCount = 0; + + interface NameConcatRequest { + first: string; + last: string; + } + + interface NameConcatResponse { + combined: string; + } + + before(() => { + functions.typed( + 'nameConcatFunc', + req => { + callCount += 1; + return { + combined: req.first + req.last, + }; + } + ); + + // Async function is expected to test asynchronous codepaths for echo handling. + functions.typed( + 'nameConcatFuncAsync', + req => { + callCount += 1; + return new Promise(accept => + setImmediate(() => accept({combined: req.first + req.last})) + ); + } + ); + + // Error prone function tests simply throwing an error a synchronous function + functions.typed( + 'errorProneTypedFunc', + () => { + callCount += 1; + throw new Error('synchronous error'); + } + ); + + // Error prone async function throws an asynchronous error + functions.typed( + 'errorProneAsyncTypedFunc', + () => { + callCount += 1; + return new Promise((_, reject) => + setImmediate(() => reject(new Error('async error'))) + ); + } + ); + }); + + beforeEach(() => { + callCount = 0; + // Prevent log spew from the PubSub emulator request. + sinon.stub(console, 'error'); + }); + + afterEach(() => { + (console.error as sinon.SinonSpy).restore(); + }); + + const testData = [ + { + func: 'nameConcatFunc', + name: "basic POST request to '/'/", + requestBody: {first: 'Jane', last: 'Doe'}, + expectedBody: {combined: 'JaneDoe'}, + expectedStatus: 200, + expectedCallCount: 1, + }, + { + func: 'nameConcatFunc', + name: 'POST malformatted JSON', + requestBody: 'ASDF', + expectedBody: /Failed to parse malformatted JSON in request.*/, + expectedStatus: 400, + expectedCallCount: 0, + }, + { + func: 'nameConcatFunc', + name: 'POST /foo PATH', + path: '/foo', + requestBody: {first: 'Jane', last: 'Doe'}, + expectedBody: {combined: 'JaneDoe'}, + expectedStatus: 200, + expectedCallCount: 1, + }, + { + func: 'nameConcatFuncAsync', + name: "async basic POST request to '/'", + requestBody: {first: 'Jane', last: 'Doe'}, + expectedBody: {combined: 'JaneDoe'}, + expectedStatus: 200, + expectedCallCount: 1, + }, + { + func: 'errorProneTypedFunc', + name: 'error in synchronous function', + expectedStatus: 500, + expectedCallCount: 1, + }, + { + func: 'errorProneAsyncTypedFunc', + name: 'error in async function', + expectedStatus: 500, + expectedCallCount: 1, + }, + ]; + + it("does not support 'GET' HTTP verb", async () => { + const st = supertest(getTestServer('nameConcatFunc')); + await st.get('/').expect(404); + }); + + testData.forEach(test => { + it(test.name, async () => { + const st = supertest(getTestServer(test.func)); + + const tc = st + .post(test.path || '/') + .send(test.requestBody || {}) + .set('Content-Type', 'application/json'); + if (test.expectedBody) { + tc.expect(test.expectedBody); + } + if (test.expectedStatus) { + tc.expect(test.expectedStatus); + } + await tc; + assert.strictEqual(callCount, test.expectedCallCount); + }); + }); +});