From c13d01731175efb901493c48ab7101ff417c733d Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Wed, 27 Apr 2022 14:56:20 -0600 Subject: [PATCH] Making typed function handlers easier (#33) * adding types for Slack Function handlers * adding tests and handling cases with no input or output params * adding tasks * clarify things a little * adding better assertions on function handler type tests --- .gitignore | 2 + deno.jsonc | 6 + src/dev_deps.ts | 1 + .../base_function_handler_type_test.ts | 122 ++++++++++++ src/functions/function_tester.ts | 10 +- src/functions/function_tester_test.ts | 13 ++ .../slack_function_handler_type_test.ts | 188 ++++++++++++++++++ src/functions/types.ts | 110 +++++++++- src/parameters/mod.ts | 4 +- src/types.ts | 7 +- 10 files changed, 448 insertions(+), 15 deletions(-) create mode 100644 .gitignore create mode 100644 deno.jsonc create mode 100644 src/functions/base_function_handler_type_test.ts create mode 100644 src/functions/slack_function_handler_type_test.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..e07aa5bd --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +.coverage diff --git a/deno.jsonc b/deno.jsonc new file mode 100644 index 00000000..378da66b --- /dev/null +++ b/deno.jsonc @@ -0,0 +1,6 @@ +{ + "tasks": { + "test": "deno test src && deno fmt --check src && deno lint src", + "coverage": "deno test --allow-read --coverage=.coverage && deno coverage --exclude=fixtures|test .coverage" + } +} diff --git a/src/dev_deps.ts b/src/dev_deps.ts index d63ba4a4..b73866dd 100644 --- a/src/dev_deps.ts +++ b/src/dev_deps.ts @@ -1,4 +1,5 @@ export { assertEquals, + assertExists, assertStrictEquals, } from "https://deno.land/std@0.134.0/testing/asserts.ts"; diff --git a/src/functions/base_function_handler_type_test.ts b/src/functions/base_function_handler_type_test.ts new file mode 100644 index 00000000..ae44619a --- /dev/null +++ b/src/functions/base_function_handler_type_test.ts @@ -0,0 +1,122 @@ +import { assertEquals } from "../dev_deps.ts"; +import { SlackFunctionTester } from "./function_tester.ts"; +import { BaseSlackFunctionHandler } from "./types.ts"; + +// These tests are to ensure our Function Handler types are supporting the use cases we want to +// Any "failures" here will most likely be reflected in Type errors + +Deno.test("BaseSlackFunctionHandler types", () => { + type Inputs = { + in: string; + }; + type Outputs = { + out: string; + }; + const handler: BaseSlackFunctionHandler = ( + { inputs }, + ) => { + return { + outputs: { + out: inputs.in, + }, + }; + }; + const { createContext } = SlackFunctionTester("test"); + const inputs = { in: "test" }; + const result = handler(createContext({ inputs })); + assertEquals(result.outputs?.out, inputs.in); +}); + +Deno.test("BaseSlackFunctionHandler with empty inputs and outputs", () => { + type Inputs = Record; + type Outputs = Record; + const handler: BaseSlackFunctionHandler = () => { + return { + outputs: {}, + }; + }; + const { createContext } = SlackFunctionTester("test"); + const result = handler(createContext({ inputs: {} })); + assertEquals(result.outputs, {}); +}); + +Deno.test("BaseSlackFunctionHandler with undefined inputs and outputs", () => { + type Inputs = undefined; + type Outputs = undefined; + const handler: BaseSlackFunctionHandler = () => { + return { + outputs: {}, + }; + }; + const { createContext } = SlackFunctionTester("test"); + const result = handler(createContext({ inputs: undefined })); + assertEquals(result.outputs, {}); +}); + +Deno.test("BaseSlackFunctionHandler with inputs and empty outputs", () => { + type Inputs = { + in: string; + }; + type Outputs = Record; + const handler: BaseSlackFunctionHandler = ({ inputs }) => { + const _test = inputs.in; + + return { + outputs: {}, + }; + }; + const { createContext } = SlackFunctionTester("test"); + const inputs = { in: "test" }; + const result = handler(createContext({ inputs })); + assertEquals(result.outputs, {}); +}); + +Deno.test("BaseSlackFunctionHandler with empty inputs and outputs", () => { + type Inputs = Record; + type Outputs = { + out: string; + }; + const handler: BaseSlackFunctionHandler = () => { + return { + outputs: { + out: "test", + }, + }; + }; + const { createContext } = SlackFunctionTester("test"); + const result = handler(createContext({ inputs: {} })); + assertEquals(result.outputs?.out, "test"); +}); + +Deno.test("BaseSlackFunctionHandler with any inputs and any outputs", () => { + // deno-lint-ignore no-explicit-any + const handler: BaseSlackFunctionHandler = ({ inputs }) => { + return { + outputs: { + out: inputs.in, + }, + }; + }; + const { createContext } = SlackFunctionTester("test"); + const inputs = { in: "test" }; + const result = handler(createContext({ inputs })); + assertEquals(result.outputs?.out, inputs.in); +}); + +Deno.test("BaseSlackFunctionHandler with set inputs and any outputs", () => { + type Inputs = { + in: string; + }; + // deno-lint-ignore no-explicit-any + const handler: BaseSlackFunctionHandler = ({ inputs }) => { + return { + outputs: { + out: inputs.in, + }, + }; + }; + const { createContext } = SlackFunctionTester("test"); + const inputs = { in: "test" }; + const result = handler(createContext({ inputs })); + assertEquals(result.outputs?.out, inputs.in); +}); diff --git a/src/functions/function_tester.ts b/src/functions/function_tester.ts index 8244109d..a5c9134f 100644 --- a/src/functions/function_tester.ts +++ b/src/functions/function_tester.ts @@ -1,6 +1,10 @@ import type { FunctionContext } from "./types.ts"; -type SlackFunctionTesterArgs = - & Partial> +type SlackFunctionTesterArgs< + InputParameters, +> = + & Partial< + FunctionContext + > & { inputs: InputParameters; }; @@ -15,7 +19,7 @@ export const SlackFunctionTester = (callbackId: string) => { const ts = new Date(); return { - inputs: args.inputs, + inputs: (args.inputs || {}) as InputParameters, env: args.env || {}, token: args.token || "slack-function-test-token", event: args.event || { diff --git a/src/functions/function_tester_test.ts b/src/functions/function_tester_test.ts index a7b9a701..6d69020a 100644 --- a/src/functions/function_tester_test.ts +++ b/src/functions/function_tester_test.ts @@ -18,3 +18,16 @@ Deno.test("SlackFunctionTester.createContext", () => { assertEquals(ctx.event.type, "function_executed"); assertEquals(ctx.event.function.callback_id, callbackId); }); + +Deno.test("SlackFunctionTester.createContext with empty inputs", () => { + const callbackId = "my_callback_id"; + const { createContext } = SlackFunctionTester(callbackId); + + const ctx = createContext({ inputs: {} }); + + assertEquals(ctx.inputs, {}); + assertEquals(ctx.env, {}); + assertEquals(typeof ctx.token, "string"); + assertEquals(ctx.event.type, "function_executed"); + assertEquals(ctx.event.function.callback_id, callbackId); +}); diff --git a/src/functions/slack_function_handler_type_test.ts b/src/functions/slack_function_handler_type_test.ts new file mode 100644 index 00000000..67e2a545 --- /dev/null +++ b/src/functions/slack_function_handler_type_test.ts @@ -0,0 +1,188 @@ +import { assertEquals } from "../dev_deps.ts"; +import { SlackFunctionTester } from "./function_tester.ts"; +import { DefineFunction } from "./mod.ts"; +import { SlackFunctionHandler } from "./types.ts"; + +// These tests are to ensure our Function Handler types are supporting the use cases we want to +// Any "failures" here will most likely be reflected in Type errors + +Deno.test("SlackFunctionHandler with inputs and outputs", () => { + const TestFn = DefineFunction({ + callback_id: "test", + title: "test fn", + source_file: "test.ts", + input_parameters: { + properties: { + in: { + type: "string", + }, + }, + required: ["in"], + }, + output_parameters: { + properties: { + out: { + type: "string", + }, + }, + required: ["out"], + }, + }); + const handler: SlackFunctionHandler = ( + { inputs }, + ) => { + return { + outputs: { + out: inputs.in, + }, + }; + }; + const { createContext } = SlackFunctionTester("test"); + const inputs = { in: "test" }; + const result = handler(createContext({ inputs })); + assertEquals(result.outputs?.out, inputs.in); +}); + +Deno.test("SlackFunctionHandler with optional input", () => { + const TestFn = DefineFunction({ + callback_id: "test", + title: "test fn", + source_file: "test.ts", + input_parameters: { + properties: { + in: { + type: "string", + }, + }, + required: [], + }, + output_parameters: { + properties: { + out: { + type: "string", + }, + }, + required: ["out"], + }, + }); + const handler: SlackFunctionHandler = ( + { inputs }, + ) => { + return { + outputs: { + out: inputs.in || "default", + }, + }; + }; + const { createContext } = SlackFunctionTester("test"); + const inputs = {}; + const result = handler(createContext({ inputs })); + assertEquals(result.outputs?.out, "default"); +}); + +Deno.test("SlackFunctionHandler with no inputs or outputs", () => { + const TestFn = DefineFunction({ + callback_id: "test", + title: "test fn", + source_file: "test.ts", + }); + const handler: SlackFunctionHandler = () => { + return { + outputs: {}, + }; + }; + const { createContext } = SlackFunctionTester("test"); + const result = handler(createContext({ inputs: {} })); + assertEquals(result.outputs, {}); +}); + +Deno.test("SlackFunctionHandler with undefined inputs and outputs", () => { + const TestFn = DefineFunction({ + callback_id: "test", + title: "test fn", + source_file: "test.ts", + input_parameters: undefined, + output_parameters: undefined, + }); + const handler: SlackFunctionHandler = () => { + return { + outputs: {}, + }; + }; + const { createContext } = SlackFunctionTester("test"); + const result = handler(createContext({ inputs: {} })); + assertEquals(result.outputs, {}); +}); + +Deno.test("SlackFunctionHandler with empty inputs and outputs", () => { + const TestFn = DefineFunction({ + callback_id: "test", + title: "test fn", + source_file: "test.ts", + input_parameters: { properties: {}, required: [] }, + output_parameters: { properties: {}, required: [] }, + }); + const handler: SlackFunctionHandler = () => { + return { + outputs: {}, + }; + }; + const { createContext } = SlackFunctionTester("test"); + const result = handler(createContext({ inputs: {} })); + assertEquals(result.outputs, {}); +}); + +Deno.test("SlackFunctionHandler with only inputs", () => { + const TestFn = DefineFunction({ + callback_id: "test", + title: "test fn", + source_file: "test.ts", + input_parameters: { + properties: { + in: { + type: "string", + }, + }, + required: ["in"], + }, + }); + const handler: SlackFunctionHandler = ( + { inputs }, + ) => { + const _test = inputs.in; + + return { + outputs: {}, + }; + }; + const { createContext } = SlackFunctionTester("test"); + const inputs = { in: "test" }; + const result = handler(createContext({ inputs })); + assertEquals(result.outputs, {}); +}); + +Deno.test("SlackFunctionHandler with only outputs", () => { + const TestFn = DefineFunction({ + callback_id: "test", + title: "test fn", + source_file: "test.ts", + output_parameters: { + properties: { + out: { + type: "string", + }, + }, + required: ["out"], + }, + }); + const handler: SlackFunctionHandler = () => { + return { + outputs: { + out: "test", + }, + }; + }; + const { createContext } = SlackFunctionTester("test"); + const result = handler(createContext({ inputs: {} })); + assertEquals(result.outputs?.out, "test"); +}); diff --git a/src/functions/types.ts b/src/functions/types.ts index 6045f840..5f5d2270 100644 --- a/src/functions/types.ts +++ b/src/functions/types.ts @@ -1,8 +1,12 @@ import { Env, ManifestFunctionSchema } from "../types.ts"; import { + ParameterDefinition, ParameterSetDefinition, RequiredParameters, } from "../parameters/mod.ts"; +import { TypedArrayParameterDefinition } from "../parameters/types.ts"; +import type SchemaTypes from "../schema/schema_types.ts"; +import type SlackSchemaTypes from "../schema/slack/schema_types.ts"; import { SlackManifest } from "../manifest.ts"; export type FunctionInvocationBody = { @@ -24,6 +28,55 @@ export type FunctionInvocationBody = { }; }; +/** + * @description Maps a ParameterDefinition into a runtime type, i.e. "string" === string. + */ +type FunctionInputRuntimeType = + Param["type"] extends typeof SchemaTypes.string ? string + : // : Param["type"] extends + // | typeof SchemaTypes.integer + // | typeof SchemaTypes.number ? number + Param["type"] extends typeof SchemaTypes.boolean ? boolean + : Param["type"] extends typeof SchemaTypes.array + ? Param extends TypedArrayParameterDefinition + ? TypedArrayFunctionInputRuntimeType + : UnknownRuntimeType[] + : // : Param["type"] extends typeof SchemaTypes.object + // ? Param extends TypedObjectParameterDefinition + // ? TypedObjectFunctionInputRuntimeType + // : UnknownRuntimeType + Param["type"] extends + | typeof SlackSchemaTypes.user_id + // | typeof SlackSchemaTypes.usergroup_id + | typeof SlackSchemaTypes.channel_id ? string + : // : Param["type"] extends typeof SlackSchemaTypes.timestamp ? number + UnknownRuntimeType; + +// deno-lint-ignore no-explicit-any +type UnknownRuntimeType = any; + +type TypedArrayFunctionInputRuntimeType< + Param extends TypedArrayParameterDefinition, +> = FunctionInputRuntimeType[]; + +/** + * @description Converts a ParameterSetDefinition, and list of required params into an object type used for runtime inputs and outputs + */ +type FunctionRuntimeParameters< + Params extends ParameterSetDefinition, + RequiredParams extends RequiredParameters, +> = + & { + [k in RequiredParams[number]]: FunctionInputRuntimeType< + Params[k] + >; + } + & { + [k in keyof Params]?: FunctionInputRuntimeType< + Params[k] + >; + }; + type AsyncFunctionHandler = { ( context: FunctionContext, @@ -36,33 +89,74 @@ type SyncFunctionHandler = { ): FunctionHandlerReturnArgs; }; -export type FunctionHandler = +/** + * @description Slack Function handler from a function definition + */ +export type SlackFunctionHandler = Definition extends + FunctionDefinitionArgs + ? BaseSlackFunctionHandler< + FunctionRuntimeParameters, + FunctionRuntimeParameters + > + : never; + +/** + * @description Slack Function handler from input and output types directly + */ +export type BaseSlackFunctionHandler< + InputParameters extends FunctionParameters, + OutputParameters extends FunctionParameters, +> = | AsyncFunctionHandler | SyncFunctionHandler; -type SuccessfulFunctionReturnArgs = { +type SuccessfulFunctionReturnArgs< + OutputParameters extends FunctionParameters, +> = { completed?: boolean; - outputs: OutputParameters; + // Allow function to return an empty object if no outputs are defined + outputs: OutputParameters extends undefined ? (Record) + : OutputParameters; error?: string; }; +// Exporting this alias for backwards compatability +/** + * @deprecated Use either SlackFunctionHandler or BaseSlackFunctionHandler + */ +export type FunctionHandler = BaseSlackFunctionHandler; + type ErroredFunctionReturnArgs = & Partial> & Required, "error">>; -export type FunctionHandlerReturnArgs = +export type FunctionHandlerReturnArgs< + OutputParameters, +> = | SuccessfulFunctionReturnArgs | ErroredFunctionReturnArgs; -export type FunctionContext = { - /** A map of string keys to string values containing any environment variables available and provided to your function handler's execution context. */ +export type FunctionContext< + InputParameters extends FunctionParameters, +> = { + /** + * @description A map of string keys to string values containing any environment variables available and provided to your function handler's execution context. + */ env: Env; - /** The inputs to the function as defined by your function definition. */ - // TODO: Support types generated from manifest + /** + * @description The inputs to the function as defined by your function definition. If no inputs are specified, an empty object is provided at runtime. + */ inputs: InputParameters; token: string; event: FunctionInvocationBody["event"]; }; + +// Allow undefined here for functions that have no inputs and/or outputs +export type FunctionParameters = { + // deno-lint-ignore no-explicit-any + [key: string]: any; +} | undefined; + export interface ISlackFunction< InputParameters extends ParameterSetDefinition, OutputParameters extends ParameterSetDefinition, diff --git a/src/parameters/mod.ts b/src/parameters/mod.ts index 3a69dcd0..709a7721 100644 --- a/src/parameters/mod.ts +++ b/src/parameters/mod.ts @@ -17,9 +17,7 @@ export type ParameterSetDefinition = { export type RequiredParameters< ParameterSetInternal extends ParameterSetDefinition, -> = { - [index: number]: keyof ParameterSetInternal; -}; +> = (keyof ParameterSetInternal)[]; export type ParameterPropertiesDefinition< Parameters extends ParameterSetDefinition, diff --git a/src/types.ts b/src/types.ts index d51716dd..14f767d1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,7 +9,12 @@ import type { ICustomType } from "./types/types.ts"; // SlackManifestType is the top level type that imports all resources for the app // An app manifest is generated based on what this has defined in it -export type { FunctionHandler } from "./functions/types.ts"; +export type { + BaseSlackFunctionHandler, + FunctionHandler, // Deprecated + SlackFunctionHandler, +} from "./functions/types.ts"; + export type SlackManifestType = { name: string; backgroundColor?: string;