Added the new integrations system into the mono-repo

This commit is contained in:
Matt Aitken
2023-02-13 14:48:12 +00:00
parent bacd76c8e5
commit fc1dd44f39
54 changed files with 34519 additions and 90 deletions
+11
View File
@@ -17,6 +17,17 @@
"name": "Chrome webapp",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}/apps/webapp/app"
},
{
"type": "node",
"request": "launch",
"name": "Debug Current Test File",
"autoAttachChildProcesses": true,
"skipFiles": ["<node_internals>/**", "**/node_modules/**"],
"program": "${workspaceRoot}/node_modules/vitest/vitest.mjs",
"args": ["run", "${relativeFile}"],
"smartStep": true,
"console": "integratedTerminal"
}
]
}
+16
View File
@@ -0,0 +1,16 @@
{
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"prettier"
],
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"],
"root": true,
"rules": {
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": "off",
"no-console": "off"
}
}
+105
View File
@@ -0,0 +1,105 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and *not* Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
.DS_Store
+4
View File
@@ -0,0 +1,4 @@
node_modules
/build
.env
+2
View File
@@ -0,0 +1,2 @@
# trigger-integrations
You can create API integrations using this data and system
+41
View File
@@ -0,0 +1,41 @@
{
"private": true,
"name": "integrations",
"version": "1.0.0",
"description": "API integrations in a format that can be used to generate clients",
"main": "src/index.ts",
"scripts": {
"test": "vitest",
"lint": "eslint src/**",
"lint-fix": "eslint --fix src/**",
"generate-sdks": "tsx src/trigger/sdk/generate.ts"
},
"dependencies": {
"@cfworker/json-schema": "^1.12.5",
"@trigger.dev/sdk": "^0.2.13",
"json-pointer": "^0.6.2",
"json-schema-deref-sync": "^0.14.0",
"json-schema-to-typescript": "^11.0.3",
"node-fetch": "^3.3.0"
},
"devDependencies": {
"@types/eslint": "^8.4.6",
"@types/json-pointer": "^1.0.31",
"@types/node": "^18.13.0",
"@typescript-eslint/eslint-plugin": "^5.51.0",
"eslint": "^8.34.0",
"eslint-config-prettier": "^8.5.0",
"eslint-config-standard-with-typescript": "^34.0.0",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-n": "^15.6.1",
"eslint-plugin-promise": "^6.1.1",
"rimraf": "^4.1.2",
"ts-morph": "^17.0.1",
"tsup": "^6.6.2",
"tsx": "^3.12.3",
"typescript": "^4.9.5",
"vite": "^4.1.1",
"vite-tsconfig-paths": "^4.0.5",
"vitest": "^0.28.4"
}
}
@@ -0,0 +1,28 @@
import { CacheService } from "core/cache/types";
import { Endpoint } from "core/endpoint/types";
import { RequestData } from "core/request/types";
import { Metadata } from "./types";
import { makeInputSpec, makeOutputSpec } from "./utilities";
export const makeRequestAction = (endpoint: Endpoint) => {
const action = async (
data: RequestData,
cache?: CacheService,
metadata?: Metadata
) => {
//a simple request doesn't use the cache or metadata
return await endpoint.request(data);
};
return {
name: endpoint.spec.endpointSpec.metadata.name,
description: endpoint.spec.endpointSpec.metadata.description,
path: endpoint.spec.endpointSpec.path,
method: endpoint.spec.endpointSpec.method,
spec: {
input: makeInputSpec(endpoint),
output: makeOutputSpec(endpoint),
},
action,
};
};
@@ -0,0 +1,34 @@
import { CacheService } from "core/cache/types";
import { EndpointSpec, HTTPMethod } from "core/endpoint/types";
import { RequestData, RequestResponse } from "core/request/types";
export type InputSpec = {
security?: EndpointSpec["security"];
parameters?: EndpointSpec["parameters"];
body?: NonNullable<EndpointSpec["request"]["body"]>["schema"];
};
export type OutputSpec = {
responses: EndpointSpec["responses"];
};
export type Metadata = Record<string, string>;
export type Action = {
name: string;
description: string;
path: string;
method: HTTPMethod;
spec: {
input: InputSpec;
output: OutputSpec;
};
action: (
/** The data to be sent to the endpoint */
data: RequestData,
/** The cache service to use for caching */
cache?: CacheService,
/** Additional metadata that can be used to modify the request */
metadata?: Metadata
) => Promise<RequestResponse>;
};
@@ -0,0 +1,48 @@
import { Endpoint } from "core/endpoint/types";
export function makeInputSpec(endpoint: Endpoint) {
return {
security: endpoint.spec.endpointSpec.security,
parameters: endpoint.spec.endpointSpec.parameters,
body: endpoint.spec.endpointSpec.request.body?.schema,
};
}
export function makeOutputSpec(endpoint: Endpoint) {
return {
responses: endpoint.spec.endpointSpec.responses,
};
}
export function combineSecurityScopes(
securities: (Record<string, string[]> | undefined)[]
): Record<string, string[]> | undefined {
const securityA = securities[0];
const securityB = securities[1];
//where a key already exists, concatenate the scope arrays together
//where a key does not exist, add it to the object
let combined: Record<string, string[]> = {};
if (securityA) {
combined = {
...securityA,
};
}
if (securityB) {
for (const key in securityB) {
if (combined[key]) {
combined[key] = [...combined[key], ...securityB[key]];
} else {
combined[key] = securityB[key];
}
}
}
if (securities.length > 2) {
return combineSecurityScopes([combined, ...securities.slice(2)]);
}
return combined;
}
@@ -0,0 +1,82 @@
import { EndpointSpec } from "core/endpoint/types";
import { FetchConfig } from "core/request/types";
import { expect, test } from "vitest";
import { checkRequiredScopes, applyCredentials } from "./credentials";
import { AuthCredentials, IntegrationAuthentication } from "./types";
test("Required scopes present", async () => {
const credentials: AuthCredentials = {
type: "oauth2",
name: "authName",
accessToken: "token",
scopes: ["scope1", "scope2"],
};
const requiredScopes = ["scope1", "scope2"];
const result = checkRequiredScopes(requiredScopes, credentials);
expect(result.success).toEqual(true);
});
test("Required scopes missing", async () => {
const credentials: AuthCredentials = {
type: "oauth2",
name: "authName",
accessToken: "token",
scopes: ["scope1", "scope2"],
};
const requiredScopes = ["scope1", "scope2", "scope3"];
const result = checkRequiredScopes(requiredScopes, credentials);
expect(result.success).toEqual(false);
if (result.success) throw new Error("Should not be success");
expect(result.missingScopes).toEqual(["scope3"]);
});
test("Applied credentials", async () => {
const credentials: AuthCredentials = {
type: "oauth2",
name: "authName",
accessToken: "123456",
scopes: ["scope1", "scope2"],
};
const endpointSecurity: EndpointSpec["security"] = {
authName: ["scope1", "scope2"],
};
const integrationAuthentication: IntegrationAuthentication = {
authName: {
type: "oauth2",
placement: {
in: "header",
type: "bearer",
key: "Authorization",
},
authorizationUrl: "https://example.com",
tokenUrl: "https://example.com",
flow: "accessCode",
scopes: {
scope1: "scope1",
scope2: "scope2",
},
},
};
const existingFetch: FetchConfig = {
url: "https://example.com",
method: "GET",
headers: {
"Content-Type": "application/json",
},
};
const fetchConfig = applyCredentials(existingFetch, {
endpointSecurity,
authentication: integrationAuthentication,
credentials,
});
expect(fetchConfig.headers.Authorization).toEqual("Bearer 123456");
expect(fetchConfig.headers["Content-Type"]).toEqual("application/json");
});
@@ -0,0 +1,85 @@
import { EndpointSpec } from "core/endpoint/types";
import { InsufficientScopesError } from "core/request/errors";
import { FetchConfig } from "core/request/types";
import { IntegrationAuthentication, AuthCredentials } from "./types";
/** Apply the given credentials to the given fetch config */
export function applyCredentials(
fetch: FetchConfig,
{
endpointSecurity,
authentication,
credentials,
}: {
endpointSecurity: EndpointSpec["security"];
authentication: IntegrationAuthentication;
credentials: AuthCredentials;
}
): FetchConfig {
if (endpointSecurity === undefined) return fetch;
// check if the credentials have the required scopes
const requiredScopes = endpointSecurity[credentials.name] ?? [];
const scopesCheckResult = checkRequiredScopes(requiredScopes, credentials);
if (!scopesCheckResult.success) {
const error: InsufficientScopesError = {
type: "insufficient_scopes",
missingScopes: scopesCheckResult.missingScopes,
};
throw error;
}
// apply the credentials
switch (credentials.type) {
case "oauth2": {
const authConfig = authentication[credentials.name];
switch (authConfig.placement.in) {
case "header": {
fetch.headers[
authConfig.placement.key
] = `Bearer ${credentials.accessToken}`;
return fetch;
}
}
break;
}
case "api_key": {
const authConfig = authentication[credentials.name];
if (authConfig.placement.in === "header") {
fetch.headers[authConfig.placement.key] = credentials.api_key;
}
return fetch;
}
}
throw new Error("Invalid credentials");
}
type ScopesCheckResult =
| {
success: true;
}
| {
success: false;
missingScopes: string[];
};
/** Check if the given credentials have the required scopes */
export function checkRequiredScopes(
requiredScopes: string[],
credentials: AuthCredentials
): ScopesCheckResult {
const missingScopes = requiredScopes.filter(
(scope) => !credentials.scopes.includes(scope)
);
if (missingScopes.length > 0) {
return {
success: false,
missingScopes,
};
}
return {
success: true,
};
}
@@ -0,0 +1,39 @@
export type IntegrationAuthentication = Record<
string,
AuthenticationDefinition
>
type AuthenticationDefinition = OAuth2
interface OAuth2 {
type: "oauth2";
placement: AuthenticationPlacement
authorizationUrl: string
tokenUrl: string
flow: "accessCode" | "implicit" | "password" | "application";
scopes: Record<string, string>
}
type AuthenticationPlacement = HeaderAuthentication
interface HeaderAuthentication {
in: "header";
type: "basic" | "bearer";
key: string
}
export type AuthCredentials = OAuth2Credentials | APIKeyCredentials
interface OAuth2Credentials {
type: "oauth2";
name: string
accessToken: string
scopes: string[]
}
interface APIKeyCredentials {
type: "api_key";
name: string
api_key: string
additionalFields?: Record<string, string>
scopes: string[]
}
+4
View File
@@ -0,0 +1,4 @@
export interface CacheService {
get: (key: string) => Promise<string | null>;
set: (key: string, value: string, ttl?: number) => Promise<void>;
}
+5
View File
@@ -0,0 +1,5 @@
import { Service } from "./service/types";
export type Catalog = {
services: Record<string, Service>;
};
@@ -0,0 +1,36 @@
import { IntegrationAuthentication } from "core/authentication/types";
import { requestEndpoint } from "core/request/requestEndpoint";
import { RequestData, RequestResponse, RequestSpec } from "core/request/types";
import { Endpoint, EndpointSpec } from "./types";
export const makeEndpoint = (spec: RequestSpec) => {
const request = async (data: RequestData) => {
return await requestEndpoint(spec, data);
};
return {
spec,
request,
};
};
export const makeEndpoints = <
TSpecs extends Record<string, EndpointSpec>,
K extends keyof TSpecs
>(
baseUrl: string,
authentication: IntegrationAuthentication,
specs: TSpecs
): Record<K, Endpoint> => {
const endpoints: any = {};
Object.entries(specs).forEach(([name, spec]) => {
endpoints[name as K] = makeEndpoint({
baseUrl,
endpointSpec: spec,
authentication,
});
});
return endpoints;
};
@@ -0,0 +1,64 @@
import { RequestData, RequestResponse, RequestSpec } from "core/request/types";
import { JSONSchema } from "core/schemas/types";
export type Endpoint = {
spec: RequestSpec;
request: (data: RequestData) => Promise<RequestResponse>;
};
export type HTTPMethod =
| "GET"
| "POST"
| "PUT"
| "PATCH"
| "DELETE"
| "HEAD"
| "OPTIONS"
| "TRACE";
export interface EndpointSpec {
path: string;
method: HTTPMethod;
metadata: EndpointSpecMetadata;
parameters?: EndpointSpecParameter[];
security?: Record<string, string[]>;
request: EndpointSpecRequest;
responses: { default: EndpointSpecResponse[] } & Record<
string,
EndpointSpecResponse[]
>;
}
interface EndpointSpecParameter {
name: string;
description: string;
in: "query" | "path" | "header";
required?: boolean;
schema: JSONSchema;
}
interface EndpointSpecRequest {
headers?: Record<string, string>;
body?: {
schema: JSONSchema;
};
}
export interface EndpointSpecResponse {
success: boolean;
name: string;
description?: string;
schema: JSONSchema;
}
interface EndpointSpecMetadata {
name: string;
description: string;
externalDocs?: ExternalDocs;
tags: string[];
}
interface ExternalDocs {
description: string;
url: string;
}
@@ -0,0 +1,60 @@
import { type ErrorObject } from "ajv";
export type RequestError =
| RequestBodyInvalid
| ParameterMissing
| ParametersInvalid
| ExtraParametersError
| InsufficientScopesError
| MissingResponseSpec
| ResponseBodyInvalid;
export interface RequestBodyInvalid {
type: "request_body_invalid";
errors: any[];
}
export interface ParameterMissing {
type: "missing_parameter";
parameter: {
name: string;
};
}
export interface ParametersInvalid {
type: "parameter_invalid";
parameter: {
name: string;
value: any;
};
errors: Array<{ name: string; errors: ErrorObject[] }>;
}
export interface ExtraParametersError {
type: "extra_parameters";
parameters: Array<{
name: string;
value: any;
}>;
}
export interface MissingCredentialsError {
type: "missing_credentials";
}
export interface InsufficientScopesError {
type: "insufficient_scopes";
missingScopes: string[];
}
export interface MissingResponseSpec {
type: "no_response_spec";
status: number;
}
export interface ResponseBodyInvalid {
type: "response_invalid";
errors: Array<{ name: string; errors: ErrorObject[] }>;
status: number;
body?: any;
}
@@ -0,0 +1,201 @@
import { type ErrorObject } from "ajv";
import { applyCredentials } from "core/authentication/credentials";
import { EndpointSpec, EndpointSpecResponse } from "core/endpoint/types";
import { validate } from "core/schemas/validate";
import fetch, { type Response } from "node-fetch";
import {
RequestSpec,
RequestData,
RequestResponse,
FetchConfig,
} from "./types";
export async function requestEndpoint(
{ baseUrl, endpointSpec, authentication }: RequestSpec,
{ parameters, body, credentials }: RequestData
): Promise<RequestResponse> {
const { method, security, request, responses } = endpointSpec;
let path = endpointSpec.path;
// validate the request body
const requestValid = validate(body, request.body?.schema);
if (!requestValid.success) {
throw {
type: "request_body_invalid",
errors: requestValid.errors,
};
}
let headers: Record<string, string> = {};
// validate and add the parameters
if (endpointSpec.parameters != null) {
for (const parameter of endpointSpec.parameters) {
const { name, in: location, required } = parameter;
// if the parameter is missing
if (parameters === undefined || parameters[name] == null) {
if (required) {
throw {
type: "missing_parameter",
parameter: {
name,
},
};
} else {
continue;
}
}
const element = parameters[name];
//validate the parameter against the schema
const valid = validate(element, parameter.schema);
if (!valid.success) {
throw {
type: "parameter_invalid",
parameter: {
name,
value: element,
},
errors: valid.errors,
};
}
//add the parameter
switch (location) {
case "path":
path = path.replace(`{${name}}`, element as string);
break;
case "query":
path = `${path}${path.includes("?") ? "&" : "?"}${name}=${element}`;
break;
case "header":
headers = {
...headers,
[name]: `${element}`,
};
break;
}
}
}
// add headers from the config
for (const name in request.headers) {
if (Object.prototype.hasOwnProperty.call(request.headers, name)) {
const element = request.headers[name];
headers = {
...headers,
[name]: element,
};
}
}
// build the fetch config
const url = `${baseUrl}${path}`;
let fetchConfig: FetchConfig = {
url,
method,
headers: {
...headers,
},
body: JSON.stringify(body),
};
// apply credentials
if (security != null) {
if (credentials == null) {
throw {
type: "missing_credentials",
};
}
fetchConfig = applyCredentials(fetchConfig, {
endpointSecurity: security,
authentication,
credentials,
});
}
// do the fetch and try get the JSON
const fetchObject = {
method: fetchConfig.method,
headers: fetchConfig.headers,
body: fetchConfig.body,
};
const response = await fetch(fetchConfig.url, fetchObject);
const json = await safeGetJson(response);
// validate the response against the specs
const responseSpecs = getResponseSpecsForStatusCode(
response.status,
responses
);
if (!responseSpecs) {
throw {
type: "no_response_spec",
status: response.status,
};
}
// start with the first spec and loop through them, if one succeeds then return that
const specErrors: Array<{ name: string; errors: ErrorObject[] }> = [];
for (const spec of responseSpecs) {
const responseValid = validate(json, spec.schema);
if (responseValid.success) {
return {
success: spec.success,
status: response.status,
headers: normalizeHeaders(response.headers),
body: json,
};
} else {
if (responseValid.errors != null) {
specErrors.push({ name: spec.name, errors: responseValid.errors });
}
}
}
throw {
type: "response_invalid",
status: response.status,
body: json,
errors: specErrors,
};
}
async function safeGetJson(response: Response) {
try {
return await response.json();
} catch (error) {
return undefined;
}
}
function normalizeHeaders(headers: Headers): Record<string, string> {
const normalizedHeaders: Record<string, string> = {};
headers.forEach((value, key) => {
normalizedHeaders[key.toLowerCase()] = value;
});
return normalizedHeaders;
}
/** Get the appropriate endpoint response object based on the status code. It supports wild cards like 20x and 2xx */
function getResponseSpecsForStatusCode(
statusCode: number,
endpointResponseSpecs: EndpointSpec["responses"]
): EndpointSpecResponse[] {
let specs = endpointResponseSpecs[statusCode.toString()];
if (specs) return specs;
specs =
endpointResponseSpecs[
`${statusCode.toString().charAt(0)}${statusCode.toString().charAt(1)}x`
];
if (specs) return specs;
specs = endpointResponseSpecs[`${statusCode.toString().charAt(0)}xx`];
if (specs) return specs;
return endpointResponseSpecs.default;
}
@@ -0,0 +1,30 @@
import {
IntegrationAuthentication,
AuthCredentials,
} from "core/authentication/types";
import { HTTPMethod, EndpointSpec } from "core/endpoint/types";
export interface FetchConfig {
url: string;
method: HTTPMethod;
headers: Record<string, string>;
body?: any;
}
export interface RequestSpec {
baseUrl: string;
endpointSpec: EndpointSpec;
authentication: IntegrationAuthentication;
}
export interface RequestData {
parameters?: Record<string, any>;
body?: any;
credentials?: AuthCredentials;
}
export interface RequestResponse {
success: boolean;
status: number;
headers?: Record<string, string>;
body?: any;
}
@@ -0,0 +1 @@
declare module "json-schema-deref-sync";
@@ -0,0 +1,13 @@
import { expect, test } from "vitest";
import spec from "./test-openapi-spec-v2.json";
import { dereferenceSpec, schemaFromOpenApiSpecV2 } from "./schemaBuilder";
test("Returns the correct schema", async () => {
const dereferenced = dereferenceSpec(spec);
const schema = schemaFromOpenApiSpecV2(
dereferenced,
"/paths//conversations.list/get/responses/200/schema"
)
expect(schema.type).toEqual("object");
expect(schema.additionalProperties).toEqual(false);
});
@@ -0,0 +1,15 @@
import deref from "json-schema-deref-sync";
import pointer from "json-pointer";
import { type JSONSchema } from "./types";
export function dereferenceSpec(spec: any): any {
return deref(spec);
}
// todo validate that it's a valid json schema
export function schemaFromOpenApiSpecV2(spec: any, path: string): JSONSchema {
// we need to escape the path because it contains ~ and / characters
path = path.replace(/~/g, "~0").replace(/\/\//g, "/~1");
const schema = pointer.get(spec as pointer.JsonObject, path);
return schema;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,3 @@
import { Schema } from "@cfworker/json-schema";
export type JSONSchema = Schema;
@@ -0,0 +1,22 @@
import { Validator } from "@cfworker/json-schema";
import { JSONSchema } from "./types";
export function validate(data: any, schema?: JSONSchema) {
if (!schema) {
return {
success: true as const,
};
}
const validator = new Validator(schema);
const result = validator.validate(data);
if (!result.valid) {
return {
success: false as const,
errors: result.errors,
};
}
return {
success: true as const,
};
}
@@ -0,0 +1,11 @@
import { Action } from "core/action/types";
import { IntegrationAuthentication } from "core/authentication/types";
import { Endpoint } from "core/endpoint/types";
export type Service = {
name: string;
service: string;
version: string;
authentication: IntegrationAuthentication;
actions: Record<string, Action>;
};
@@ -0,0 +1,106 @@
import { EndpointSpec } from "core/endpoint/types";
import { JSONSchema } from "core/schemas/types";
import { expect, test } from "vitest";
import { createInputSchema } from "./combineSchemas";
test("create input schema when only a body schema", async () => {
try {
const schema: JSONSchema = {
type: "object",
required: ["channel"],
properties: {
as_user: {
type: "string",
description:
"Pass true to post the message as the authed user, instead of as a bot. Defaults to false. See [authorship](#authorship) below.",
},
attachments: {
type: "string",
description:
"A JSON-based array of structured attachments, presented as a URL-encoded string.",
},
channel: {
type: "string",
description:
"Channel, private group, or IM channel to send message to. Can be an encoded ID, or a name. See [below](#channels) for more details.",
},
},
};
const inputSchema = createInputSchema({ body: schema });
expect(inputSchema).toEqual(schema);
} catch (e: any) {
console.error(JSON.stringify(e.errors, null, 2));
expect(e).toEqual(null);
}
});
test("combine input body and parameters into a schema", async () => {
try {
const bodySchema: JSONSchema = {
type: "object",
required: ["channel"],
properties: {
as_user: {
type: "string",
description:
"Pass true to post the message as the authed user, instead of as a bot. Defaults to false. See [authorship](#authorship) below.",
},
attachments: {
type: "string",
description:
"A JSON-based array of structured attachments, presented as a URL-encoded string.",
},
channel: {
type: "string",
description:
"Channel, private group, or IM channel to send message to. Can be an encoded ID, or a name. See [below](#channels) for more details.",
},
},
};
const parameters: EndpointSpec["parameters"] = [
{
name: "limit",
description: "The maximum number of items to return.",
in: "path",
required: true,
schema: {
type: "integer",
description: "The maximum number of items to return.",
},
},
];
const inputSchema = createInputSchema({ body: bodySchema, parameters });
expect(inputSchema).toMatchInlineSnapshot(`
{
"properties": {
"as_user": {
"description": "Pass true to post the message as the authed user, instead of as a bot. Defaults to false. See [authorship](#authorship) below.",
"type": "string",
},
"attachments": {
"description": "A JSON-based array of structured attachments, presented as a URL-encoded string.",
"type": "string",
},
"channel": {
"description": "Channel, private group, or IM channel to send message to. Can be an encoded ID, or a name. See [below](#channels) for more details.",
"type": "string",
},
"limit": {
"description": "The maximum number of items to return.",
"type": "integer",
},
},
"required": [
"channel",
"limit",
],
"type": "object",
}
`);
} catch (e: any) {
console.error(JSON.stringify(e.errors, null, 2));
expect(e).toEqual(null);
}
});
@@ -0,0 +1,77 @@
import { Action } from "core/action/types";
import { JSONSchema } from "core/schemas/types";
export function generateInputOutputSchemas(
spec: Action["spec"],
name: string
): {
input: JSONSchema | undefined;
output: JSONSchema;
} {
const inputSchema = createInputSchema(spec.input);
if (inputSchema) inputSchema.title = `${name}Input`;
const outputSchema = createSuccessfulOutputSchema(spec.output);
outputSchema.title = `${name}Output`;
return {
input: inputSchema,
output: outputSchema,
};
}
export function createInputSchema(
spec: Action["spec"]["input"]
): JSONSchema | undefined {
let inputSchema: JSONSchema | undefined = spec.body;
if (spec.parameters && spec.parameters.length > 0) {
if (!inputSchema) {
inputSchema = {
type: "object",
properties: {},
};
}
inputSchema = {
type: "object",
properties: {
...inputSchema.properties,
...Object.fromEntries(
spec.parameters.map((p) => [
p.name,
{ ...p.schema, description: p.description },
])
),
},
required: [
...(inputSchema.required ?? []),
...spec.parameters.filter((p) => p.required).map((p) => p.name),
],
};
}
return inputSchema;
}
export function createSuccessfulOutputSchema(
spec: Action["spec"]["output"]
): JSONSchema {
//combine all "success" output schemas into a union
const outputSuccessSchemas = Object.values(spec.responses).flatMap((s) =>
s.flatMap((r) => (r.success ? r.schema : []))
);
return outputSuccessSchemas.length === 1
? outputSuccessSchemas[0]
: createDiscriminatedUnionSchema(`Output`, outputSuccessSchemas);
}
function createDiscriminatedUnionSchema(
name: string,
schemas: JSONSchema[]
): JSONSchema {
return {
$id: name,
oneOf: schemas,
};
}
@@ -0,0 +1,119 @@
import { expect, test } from "vitest";
import { getTypesFromSchema as generateTypesFromSchema } from "./generateTypes";
test("simple schema type generation", async () => {
try {
const schema = {
type: "object",
properties: {
channel: {
type: "string",
description: "ID of conversation to join",
},
},
required: ["channel"],
};
const data = await generateTypesFromSchema(schema, "Input");
expect(data).toMatchInlineSnapshot(`
"export interface Input {
/**
* ID of conversation to join
*/
channel: string;
}
"
`);
} catch (e: any) {
console.error(JSON.stringify(e.errors, null, 2));
expect(e).toEqual(null);
}
});
test("advanced schema type generation", async () => {
try {
const schema = {
type: "object",
properties: {
channel: {
type: "string",
},
message: {
type: "object",
properties: {
attachments: {
type: "array",
items: {
type: "object",
properties: {
fallback: {
type: "string",
},
id: {
type: "number",
},
text: {
type: "string",
},
},
required: ["fallback", "id", "text"],
},
},
bot_id: {
type: "string",
},
subtype: {
type: "string",
},
text: {
type: "string",
},
ts: {
type: "string",
},
type: {
type: "string",
},
user: {
type: "string",
},
},
required: ["bot_id", "text", "ts", "type"],
},
ok: {
type: "boolean",
},
ts: {
type: "string",
},
},
required: ["channel", "message", "ok", "ts"],
};
const data = await generateTypesFromSchema(schema, "Input");
expect(data).toMatchInlineSnapshot(`
"export interface Input {
channel: string;
message: {
attachments?: {
fallback: string;
id: number;
text: string;
}[];
bot_id: string;
subtype?: string;
text: string;
ts: string;
type: string;
user?: string;
};
ok: boolean;
ts: string;
}
"
`);
} catch (e: any) {
console.error(JSON.stringify(e.errors, null, 2));
expect(e).toEqual(null);
}
});
@@ -0,0 +1,9 @@
import { compile } from "json-schema-to-typescript";
export async function getTypesFromSchema(schema: any, name: string) {
const ts = await compile(schema, name, {
additionalProperties: false,
bannerComment: "",
});
return ts;
}
@@ -0,0 +1,8 @@
import { Catalog } from "core/catalog";
import { slack } from "./slack";
export const catalog: Catalog = {
services: {
slack,
},
};
@@ -0,0 +1,146 @@
import { makeRequestAction } from "core/action/simpleAction";
import { Action } from "core/action/types";
import {
combineSecurityScopes,
makeInputSpec,
makeOutputSpec,
} from "core/action/utilities";
import { CacheService } from "core/cache/types";
import { RequestData } from "core/request/types";
import endpoints from "../endpoints/endpoints";
export const conversationsList: Action = makeRequestAction(
endpoints.conversationsList
);
export const chatPostMessage: Action = {
name: endpoints.chatPostMessage.spec.endpointSpec.metadata.name,
description: endpoints.chatPostMessage.spec.endpointSpec.metadata.description,
path: endpoints.chatPostMessage.spec.endpointSpec.path,
method: endpoints.chatPostMessage.spec.endpointSpec.method,
spec: {
input: {
...makeInputSpec(endpoints.chatPostMessage),
security: combineSecurityScopes([
endpoints.chatPostMessage.spec.endpointSpec.security,
endpoints.conversationsList.spec.endpointSpec.security,
endpoints.conversationsJoin.spec.endpointSpec.security,
]),
},
output: makeOutputSpec(endpoints.chatPostMessage),
},
action: async (data, cache, metadata) => {
//get channel id
const channelId = await getChannelId({
channel: data.body.channel,
credentials: data.credentials,
cache,
});
if (!channelId) {
return {
success: false,
status: 404,
body: {
ok: false,
error: "channel_not_found",
},
};
}
const postMessageBody = {
...data.body,
channel: channelId,
};
//todo add extra passed in metadata to the metadata property
//
const postResponse = await endpoints.chatPostMessage.request({
parameters: data.parameters,
body: postMessageBody,
credentials: data.credentials,
});
//success
if (postResponse.success) return postResponse;
//if the bot isn't a member of the channel we want to invite
if (postResponse.body?.error !== "not_in_channel") return postResponse;
//join the bot to the channel
const joinResponse = await endpoints.conversationsJoin.request({
body: {
channel: channelId,
},
credentials: data.credentials,
});
//if we can't join the channel, return the error
if (!joinResponse.success) {
return {
success: false,
status: 400,
body: {
ok: false,
error: `failed to join channel: ${joinResponse.body?.error}`,
},
};
}
//try again now we've joined the channel
return await endpoints.chatPostMessage.request({
parameters: data.parameters,
body: postMessageBody,
credentials: data.credentials,
});
},
};
async function getChannelId({
channel,
credentials,
cache,
}: {
channel: string;
credentials: RequestData["credentials"];
cache?: CacheService;
}): Promise<string | undefined> {
if (channel.startsWith("#")) {
channel = channel.substring(1);
}
const cachedChannelId = await cache?.get(channel);
if (cachedChannelId) {
return cachedChannelId;
}
try {
const data = await endpoints.conversationsList.request({
parameters: {
limit: 1000,
},
credentials,
});
if (!data.success) {
return undefined;
}
//lookup by name or id
const match = data.body.channels.find(
(channelData: any) =>
channelData.name === channel || channelData.id === channel
);
if (!match) {
return undefined;
}
//cache for 24 hours
await cache?.set(channel, match.id, 60 * 60 * 24);
return match.id;
} catch (e: any) {
console.error(JSON.stringify(e, null, 2));
return undefined;
}
}
@@ -0,0 +1,84 @@
import { IntegrationAuthentication } from "core/authentication/types";
export const authentication: IntegrationAuthentication = {
slackAuth: {
type: "oauth2",
placement: {
in: "header",
type: "bearer",
key: "Authorization",
},
authorizationUrl: "https://slack.com/oauth/authorize",
tokenUrl: "https://slack.com/api/oauth.access",
flow: "accessCode",
scopes: {
admin: "admin",
"admin.apps:read": "admin.apps:read",
"admin.apps:write": "admin.apps:write",
"admin.conversations:read": "admin.conversations:read",
"admin.conversations:write": "admin.conversations:write",
"admin.invites:read": "admin.invites:read",
"admin.invites:write": "admin.invites:write",
"admin.teams:read": "admin.teams:read",
"admin.teams:write": "admin.teams:write",
"admin.usergroups:read": "admin.usergroups:read",
"admin.usergroups:write": "admin.usergroups:write",
"admin.users:read": "admin.users:read",
"admin.users:write": "admin.users:write",
"authorizations:read": "authorizations:read",
bot: "Bot user scope",
"calls:read": "calls:read",
"calls:write": "calls:write",
"channels:history": "channels:history",
"channels:manage": "channels:manage",
"channels:read": "channels:read",
"channels:write": "channels:write",
"chat:write": "chat:write",
"chat:write:bot": "Author messages as a bot",
"chat:write:user": "Author messages as a user",
"conversations:history": "conversations:history",
"conversations:read": "conversations:read",
"conversations:write": "conversations:write",
"dnd:read": "dnd:read",
"dnd:write": "dnd:write",
"emoji:read": "emoji:read",
"files:read": "files:read",
"files:write:user": "files:write:user",
"groups:history": "groups:history",
"groups:read": "groups:read",
"groups:write": "groups:write",
"identity.basic": "identity.basic",
"im:history": "im:history",
"im:read": "im:read",
"im:write": "im:write",
"links:write": "links:write",
"mpim:history": "mpim:history",
"mpim:read": "mpim:read",
"mpim:write": "mpim:write",
none: "No scope required",
"pins:read": "pins:read",
"pins:write": "pins:write",
"reactions:read": "reactions:read",
"reactions:write": "reactions:write",
"reminders:read": "reminders:read",
"reminders:write": "reminders:write",
"remote_files:read": "remote_files:read",
"remote_files:share": "remote_files:share",
"remote_files:write": "remote_files:write",
"rtm:stream": "rtm:stream",
"search:read": "search:read",
"stars:read": "stars:read",
"stars:write": "stars:write",
"team:read": "team:read",
"tokens.basic": "tokens.basic",
"usergroups:read": "usergroups:read",
"usergroups:write": "usergroups:write",
"users.profile:read": "users.profile:read",
"users.profile:write": "users.profile:write",
"users:read": "users:read",
"users:read.email": "users:read.email",
"users:write": "users:write",
"workflow.steps:execute": "workflow.steps:execute",
},
},
};
@@ -0,0 +1,8 @@
import { makeEndpoints } from "core/endpoint/endpoint";
import { authentication } from "../authentication";
import * as specs from "./specs";
const baseUrl = "https://slack.com/api";
const endpoints = makeEndpoints(baseUrl, authentication, specs);
export default endpoints;
@@ -0,0 +1,3 @@
import { dereferenceSpec } from "core/schemas/schemaBuilder";
import rawSpec from "./slack_web_openapi_v2.json";
export const spec = dereferenceSpec(rawSpec);
@@ -0,0 +1,350 @@
import { EndpointSpec, EndpointSpecResponse } from "core/endpoint/types";
import { schemaFromOpenApiSpecV2 } from "core/schemas/schemaBuilder";
import { spec } from "./schemas/spec";
const errorResponse: EndpointSpecResponse = {
success: false,
name: "Error",
description: "200 error response",
schema: {
$schema: "http://json-schema.org/draft-07/schema#",
type: "object",
properties: {
ok: {
type: "boolean",
enum: [false],
},
error: {
type: "string",
},
},
required: ["ok", "error"],
},
};
export const chatPostMessage: EndpointSpec = {
path: "/chat.postMessage",
method: "POST",
metadata: {
name: "postMessage",
description: "Post a message to a channel",
externalDocs: {
description: "API method documentation",
url: "https://api.slack.com/methods/chat.postMessage",
},
tags: ["chat"],
},
security: {
slackAuth: ["chat:write:user", "chat:write:bot"],
},
request: {
headers: {
"Content-Type": "application/json; charset=utf-8",
},
body: {
schema: {
type: "object",
required: ["channel"],
properties: {
as_user: {
type: "string",
description:
"Pass true to post the message as the authed user, instead of as a bot. Defaults to false. See [authorship](#authorship) below.",
},
attachments: {
type: "string",
description:
"A JSON-based array of structured attachments, presented as a URL-encoded string.",
},
blocks: {
type: "string",
description:
"A JSON-based array of structured blocks, presented as a URL-encoded string.",
},
channel: {
type: "string",
description:
"Channel, private group, or IM channel to send message to. Can be an encoded ID, or a name. See [below](#channels) for more details.",
},
icon_emoji: {
type: "string",
description:
"Emoji to use as the icon for this message. Overrides `icon_url`. Must be used in conjunction with `as_user` set to `false`, otherwise ignored. See [authorship](#authorship) below.",
},
icon_url: {
type: "string",
description:
"URL to an image to use as the icon for this message. Must be used in conjunction with `as_user` set to false, otherwise ignored. See [authorship](#authorship) below.",
},
link_names: {
type: "boolean",
description: "Find and link channel names and usernames.",
},
mrkdwn: {
type: "boolean",
description:
"Disable Slack markup parsing by setting to `false`. Enabled by default.",
},
parse: {
type: "string",
description:
"Change how messages are treated. Defaults to `none`. See [below](#formatting).",
},
reply_broadcast: {
type: "boolean",
description:
"Used in conjunction with `thread_ts` and indicates whether reply should be made visible to everyone in the channel or conversation. Defaults to `false`.",
},
text: {
type: "string",
description:
"How this field works and whether it is required depends on other fields you use in your API call. [See below](#text_usage) for more detail.",
},
thread_ts: {
type: "string",
description:
"Provide another message's `ts` value to make this message a reply. Avoid using a reply's `ts` value; use its parent instead.",
},
unfurl_links: {
type: "boolean",
description:
"Pass true to enable unfurling of primarily text-based content.",
},
unfurl_media: {
type: "boolean",
description: "Pass false to disable unfurling of media content.",
},
username: {
type: "string",
description:
"Set your bot's user name. Must be used in conjunction with `as_user` set to false, otherwise ignored. See [authorship](#authorship) below.",
},
},
},
},
},
responses: {
200: [
{
success: true,
name: "Success",
description: "Typical success response",
schema: {
type: "object",
properties: {
channel: {
description: "Channel ID where the message was posted",
type: "string",
},
message: {
type: "object",
properties: {
attachments: {
type: "array",
items: {
type: "object",
properties: {
fallback: {
type: "string",
},
id: {
type: "number",
},
text: {
type: "string",
},
},
required: ["fallback", "id", "text"],
},
},
bot_id: {
type: "string",
},
subtype: {
type: "string",
},
text: {
type: "string",
},
ts: {
type: "string",
},
type: {
type: "string",
},
user: {
type: "string",
},
},
required: ["bot_id", "text", "ts", "type"],
},
ok: {
type: "boolean",
},
ts: {
type: "string",
},
},
required: ["channel", "message", "ok", "ts"],
},
},
errorResponse,
],
default: [
{
success: false,
name: "Error",
description:
"Typical error response if too many attachments are included",
schema: {
description: "Schema for error response chat.postMessage method",
type: "object",
properties: {
error: {
type: "string",
},
ok: {
type: "boolean",
},
},
required: ["error", "ok"],
},
},
],
},
};
export const conversationsList: EndpointSpec = {
path: "/conversations.list",
method: "GET",
metadata: {
name: "conversationsList",
description: "Lists all channels in a Slack team.",
externalDocs: {
description: "API method documentation",
url: "https://api.slack.com/methods/conversations.list",
},
tags: ["conversations"],
},
security: {
slackAuth: ["conversations:read"],
},
parameters: [
{
name: "exclude_archived",
in: "query",
description: "Set to `true` to exclude archived channels from the list",
schema: {
type: "boolean",
},
},
{
name: "types",
in: "query",
description:
"Mix and match channel types by providing a comma-separated list of any combination of `public_channel`, `private_channel`, `mpim`, `im`",
schema: {
type: "string",
},
},
{
name: "limit",
in: "query",
description:
"The maximum number of items to return. Fewer than the requested number of items may be returned, even if the end of the list hasn't been reached. Must be an integer no larger than 1000.",
schema: {
type: "number",
},
},
{
name: "cursor",
in: "query",
description:
'Paginate through collections of data by setting the `cursor` parameter to a `next_cursor` attribute returned by a previous request\'s `response_metadata`. Default value fetches the first "page" of the collection. See [pagination](/docs/pagination) for more detail.',
schema: {
type: "string",
},
},
],
request: {},
responses: {
200: [
{
success: true,
name: "Success",
schema: schemaFromOpenApiSpecV2(
spec,
"/paths//conversations.list/get/responses/200/schema"
),
},
errorResponse,
],
default: [
{
success: false,
name: "Error",
schema: schemaFromOpenApiSpecV2(
spec,
"/paths//conversations.list/get/responses/default/schema"
),
},
],
},
};
export const conversationsJoin: EndpointSpec = {
path: "/conversations.join",
method: "POST",
metadata: {
name: "conversationsJoin",
description: "Joins an existing conversation.",
externalDocs: {
description: "API method documentation",
url: "https://api.slack.com/methods/conversations.join",
},
tags: ["conversations"],
},
security: {
slackAuth: ["channels:write"],
},
request: {
headers: {
"Content-Type": "application/json; charset=utf-8",
},
body: {
schema: {
type: "object",
properties: {
channel: {
type: "string",
description: "ID of conversation to join",
},
},
required: ["channel"],
},
},
},
responses: {
200: [
{
success: true,
name: "Success",
schema: schemaFromOpenApiSpecV2(
spec,
"/paths//conversations.join/post/responses/200/schema"
),
},
errorResponse,
],
default: [
{
success: false,
name: "Error",
schema: schemaFromOpenApiSpecV2(
spec,
"/paths//conversations.join/post/responses/default/schema"
),
},
],
},
};
@@ -0,0 +1,11 @@
import { Service } from "core/service/types";
import { authentication } from "./authentication";
import * as actions from "./actions/actions";
export const slack: Service = {
name: "Slack",
service: "slack",
version: "1.0.0",
authentication,
actions,
};
@@ -0,0 +1,84 @@
import { expect, test } from "vitest";
import { chatPostMessage, conversationsList } from "../actions/actions";
test("/conversations.list success", async () => {
try {
const data = await conversationsList.action({
parameters: {
limit: 3,
},
credentials: {
type: "oauth2",
name: "slackAuth",
accessToken: "xoxb-276370297397-4578980839603-5mrIOR6E5KQGhOwtAYTaMC2x",
scopes: ["conversations:read"],
},
});
expect(data.success).toEqual(true);
expect(data.status).toEqual(200);
expect(data.body.ok).toEqual(true);
} catch (e: any) {
console.error(JSON.stringify(e.errors, null, 2));
expect(e).toEqual(null);
}
});
test("/chat.postMessage success with name", async () => {
try {
const data = await chatPostMessage.action({
body: {
channel: "test-integrations",
text: "Using the channel name",
},
credentials: {
type: "oauth2",
name: "slackAuth",
accessToken: "xoxb-276370297397-4578980839603-5mrIOR6E5KQGhOwtAYTaMC2x",
scopes: [
"chat:write:user",
"chat:write:bot",
"conversations:read",
"channels:write",
],
},
});
expect(data.success).toEqual(true);
expect(data.status).toEqual(200);
expect(data.body.ok).toEqual(true);
expect(data.body.message.text).toEqual("Using the channel name");
} catch (e: any) {
console.error(JSON.stringify(e, null, 2));
expect(e).toEqual(null);
}
});
test("/chat.postMessage failed with bad name", async () => {
try {
const data = await chatPostMessage.action({
body: {
channel: "this-channel-does-not-exist",
text: "Using the channel name",
},
credentials: {
type: "oauth2",
name: "slackAuth",
accessToken: "xoxb-276370297397-4578980839603-5mrIOR6E5KQGhOwtAYTaMC2x",
scopes: [
"chat:write:user",
"chat:write:bot",
"conversations:read",
"channels:write",
],
},
});
expect(data.success).toEqual(false);
expect(data.body.ok).toEqual(false);
expect(data.body.error).toEqual("channel_not_found");
} catch (e: any) {
console.error(JSON.stringify(e.errors, null, 2));
expect(e).toEqual(null);
}
});
@@ -0,0 +1,91 @@
import { expect, test } from "vitest";
import endpoints from "../endpoints/endpoints";
test("missing credentials", async () => {
try {
await endpoints.chatPostMessage.request({
body: {
channel: "C04GWUTDC3W",
text: "This the Trigger.dev integrations test",
},
});
} catch (e: any) {
expect(e.type).toEqual("missing_credentials");
}
});
test("/chat.postMessage success", async () => {
try {
const data = await endpoints.chatPostMessage.request({
body: {
channel: "C04GWUTDC3W",
text: "This the Trigger.dev integrations test",
},
credentials: {
type: "oauth2",
name: "slackAuth",
accessToken: "xoxb-276370297397-4578980839603-5mrIOR6E5KQGhOwtAYTaMC2x",
scopes: ["chat:write:user", "chat:write:bot"],
},
});
expect(data.success).toEqual(true);
expect(data.status).toEqual(200);
expect(data.body.ok).toEqual(true);
expect(data.body.channel).toEqual("C04GWUTDC3W");
expect(data.body.message.text).toEqual(
"This the Trigger.dev integrations test"
);
} catch (e: any) {
console.error(JSON.stringify(e.errors, null, 2));
expect(e).toEqual(null);
}
});
test("/chat.postMessage bad channel", async () => {
try {
const data = await endpoints.chatPostMessage.request({
body: {
channel: "C00AAAAAAAA",
text: "This channel doesn't exist, so message won't send",
},
credentials: {
type: "oauth2",
name: "slackAuth",
accessToken: "xoxb-276370297397-4578980839603-5mrIOR6E5KQGhOwtAYTaMC2x",
scopes: ["chat:write:user", "chat:write:bot"],
},
});
expect(data.success).toEqual(false);
expect(data.status).toEqual(200);
expect(data.body.ok).toEqual(false);
expect(data.body.error).toEqual("channel_not_found");
} catch (e: any) {
console.error(JSON.stringify(e, null, 2));
expect(e).toEqual(null);
}
});
test("/conversations.list success", async () => {
try {
const data = await endpoints.conversationsList.request({
parameters: {
limit: 3,
},
credentials: {
type: "oauth2",
name: "slackAuth",
accessToken: "xoxb-276370297397-4578980839603-5mrIOR6E5KQGhOwtAYTaMC2x",
scopes: ["conversations:read"],
},
});
expect(data.success).toEqual(true);
expect(data.status).toEqual(200);
expect(data.body.ok).toEqual(true);
} catch (e: any) {
console.error(JSON.stringify(e.errors, null, 2));
expect(e).toEqual(null);
}
});
@@ -0,0 +1,16 @@
import { catalog } from "integrations/catalog";
import { generateService } from "./generateService";
export async function generate() {
console.log("Generating SDKs...");
const allSdks = Object.values(catalog.services).map((service) => {
return generateService(service);
});
await Promise.all(allSdks);
console.log(`Generated ${allSdks.length} SDKs`);
}
generate();
@@ -0,0 +1,9 @@
import { slack } from "integrations/slack";
import { expect, test } from "vitest";
import { generateService } from "./generateService";
test("generate simple service", async () => {
generateService(slack);
expect(1).toEqual(1);
});
@@ -0,0 +1,176 @@
import { IndentationText, NewLineKind, Project, QuoteKind } from "ts-morph";
import { Service } from "core/service/types";
import fs from "fs/promises";
import { generateInputOutputSchemas } from "generators/combineSchemas";
import { getTypesFromSchema } from "generators/generateTypes";
import { dirname } from "path";
import rimraf from "rimraf";
const appDir = require.main ? dirname(require.main.filename) : process.cwd();
export async function generateService(service: Service) {
const basePath = `sdks/@trigger.dev/${service.service}`;
//remove folder
const absolutePath = `${appDir}/${basePath}/`;
console.log(`Removing ${absolutePath}...`);
rimraf.sync(absolutePath);
console.log(`Generating SDK for ${service.service}...`);
const project = new Project({
manipulationSettings: {
indentationText: IndentationText.TwoSpaces,
newLineKind: NewLineKind.LineFeed,
quoteKind: QuoteKind.Double,
usePrefixAndSuffixTextForRename: false,
useTrailingCommas: true,
},
});
try {
project.createDirectory(basePath);
await generateTemplatedFiles(project, basePath, service);
await generateFunctionsAndTypes(project, basePath, service);
await project.save();
} catch (e) {
console.error(e);
}
}
function toFriendlyTypeName(original: string) {
//convert the input string to TitleCase, strip out any non alpha characters and strip out spaces
return original
.replace(/([A-Z])/g, " $1")
.replace(/^./, function (str: string) {
return str.toUpperCase();
})
.replace(/[^a-zA-Z]/g, "")
.replace(/\s/g, "");
}
async function generateTemplatedFiles(
project: Project,
basePath: string,
service: Service
) {
await createFileAndReplaceVariables(
"package.json",
project,
basePath,
service
);
await createFileAndReplaceVariables(
"tsconfig.json",
project,
basePath,
service
);
await createFileAndReplaceVariables("README.md", project, basePath, service);
await createFileAndReplaceVariables(
"tsup.config.ts",
project,
basePath,
service
);
return;
}
async function createFileAndReplaceVariables(
filename: string,
project: Project,
basePath: string,
service: Service
) {
const originalText = await fs.readFile(
`src/trigger/sdk/templates/${filename}`,
{ encoding: "utf-8" }
);
//replace any text that matches {service.[key]} with the value from the service object
const text = originalText.replace(
/{service.([a-zA-Z0-9]+)}/g,
(match: string, key: string) => {
return (service as any)[key] as string;
}
);
const file = project.createSourceFile(`${basePath}/${filename}`, text, {
overwrite: true,
});
file.formatText();
return;
}
async function generateFunctionsAndTypes(
project: Project,
basePath: string,
service: Service
) {
const { actions } = service;
const typeDefinitions: Record<string, string> = {};
const functions: Record<string, string> = {};
//loop through actions
for (const key in actions) {
const action = actions[key];
//generate schemas for input and output
const name = toFriendlyTypeName(action.name);
const schemas = generateInputOutputSchemas(action.spec, name);
//generate types for input and output
const inputTypeName = `${name}Input`;
const inputType = await getTypesFromSchema(schemas.input, inputTypeName);
typeDefinitions[inputTypeName] = inputType;
const outputTypeName = `${name}Output`;
const outputType = await getTypesFromSchema(schemas.output, outputTypeName);
typeDefinitions[outputTypeName] = outputType;
functions[action.name] = `
${action.description ? `/** ${action.description} */` : ""}
export async function ${action.name}(
/** This key should be unique inside your workflow */
key: string,
/** The params for this call */
params: ${inputTypeName}
): Promise<${outputTypeName}> {
const run = getTriggerRun();
if (!run) {
throw new Error("Cannot call ${action.name} outside of a trigger run");
}
const output = await run.performRequest(key, {
version: "2",
service: "${service.service}",
endpoint: "${action.path}",
params,
});
return output;
}
`;
}
const typesFile = project.createSourceFile(
`${basePath}/src/types.ts`,
Object.values(typeDefinitions).join("\n\n"),
{
overwrite: true,
}
);
typesFile.formatText();
const functionsFile = project.createSourceFile(
`${basePath}/src/index.ts`,
`import { getTriggerRun } from "@trigger.dev/sdk";
import { ${Object.keys(typeDefinitions).join(", ")} } from "./types";
${Object.values(functions).join("")}`,
{
overwrite: true,
}
);
functionsFile.formatText();
}
@@ -0,0 +1,3 @@
# Trigger.dev {service.name} integration
View more documentation [here](https://docs.trigger.dev)
@@ -0,0 +1,33 @@
{
"name": "@trigger.dev/{service.service}",
"version": "{service.version}",
"description": "The official {service.name} integration for Trigger.dev",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"publishConfig": {
"access": "public"
},
"files": [
"dist/index.js",
"dist/index.d.ts",
"dist/index.js.map"
],
"devDependencies": {
"@trigger.dev/sdk": "workspace:*",
"@types/debug": "^4.1.7",
"@types/node": "16",
"rimraf": "^3.0.2",
"tsup": "^6.5.0"
},
"peerDependencies": {
"@trigger.dev/sdk": "workspace:*"
},
"scripts": {
"clean": "rimraf dist",
"build": "npm run clean && npm run build:tsup",
"build:tsup": "tsup"
},
"dependencies": {
"debug": "^4.3.4"
}
}
@@ -0,0 +1,21 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Node 16",
"include": ["./src/**/*.ts", "tsup.config.ts"],
"compilerOptions": {
"module": "commonjs",
"target": "ES2021",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"lib": ["DOM", "DOM.Iterable", "ES2019"],
"paths": {
"@trigger.dev/sdk/*": ["../../packages/trigger-sdk/src/*"],
"@trigger.dev/sdk": ["../../packages/trigger-sdk/src/index"]
}
},
"exclude": ["node_modules"]
}
@@ -0,0 +1,22 @@
import { defineConfig } from "tsup";
export default defineConfig([
{
name: "main",
entry: ["./src/index.ts"],
outDir: "./dist",
platform: "node",
format: ["cjs"],
legacyOutput: true,
sourcemap: true,
clean: true,
bundle: true,
splitting: false,
dts: true,
treeshake: {
preset: "smallest",
},
external: ["http", "https", "util", "events", "tty", "os", "timers"],
esbuildPlugins: [],
},
]);
+19
View File
@@ -0,0 +1,19 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Node 16",
"compilerOptions": {
"baseUrl": "./src",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"lib": ["DOM", "DOM.Iterable", "ES2019"],
"module": "commonjs",
"target": "ES2021",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["./src/**/*.ts", "./src/**/*.d.ts"],
"exclude": ["node_modules"]
}
+12
View File
@@ -0,0 +1,12 @@
/// <reference types="vitest" />
/// <reference types="vite/client" />
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [tsconfigPaths()],
test: {
globals: true,
},
});
+1 -1
View File
@@ -196,7 +196,7 @@
"typescript": "^4.8.4",
"vite": "^3.1.4",
"vite-tsconfig-paths": "^3.5.1",
"vitest": "^0.23.4"
"vitest": "^0.28.4"
},
"engines": {
"node": ">=16.0.0"
+6 -2
View File
@@ -43,7 +43,8 @@
"changeset:next": "changeset pre enter next",
"changeset:normal": "changeset pre exit",
"sentry-upload": "turbo run sentry-upload",
"clean:sourcemaps": "turbo run clean:sourcemaps"
"clean:sourcemaps": "turbo run clean:sourcemaps",
"generate-sdks": "turbo run generate-sdks"
},
"devDependencies": {
"@manypkg/cli": "^0.19.2",
@@ -55,7 +56,10 @@
"prettier": "^2.5.1",
"tailwindcss": "3.1.8",
"tsx": "^3.7.1",
"turbo": "^1.5.5"
"turbo": "^1.5.5",
"vite": "^4.1.1",
"vite-tsconfig-paths": "^4.0.5",
"vitest": "^0.28.4"
},
"packageManager": "pnpm@7.13.5",
"dependencies": {
+1852 -87
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -82,6 +82,9 @@
},
"clean:sourcemaps": {
"cache": false
},
"generate-sdks": {
"cache": false
}
},
"globalDependencies": [".env"],
+12
View File
@@ -0,0 +1,12 @@
/// <reference types="vitest" />
/// <reference types="vite/client" />
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [tsconfigPaths()],
test: {
globals: true,
},
});