Added the new integrations system into the mono-repo
This commit is contained in:
Vendored
+11
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
|
||||
/build
|
||||
.env
|
||||
@@ -0,0 +1,2 @@
|
||||
# trigger-integrations
|
||||
You can create API integrations using this data and system
|
||||
@@ -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
@@ -0,0 +1,4 @@
|
||||
export interface CacheService {
|
||||
get: (key: string) => Promise<string | null>;
|
||||
set: (key: string, value: string, ttl?: number) => Promise<void>;
|
||||
}
|
||||
@@ -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;
|
||||
+27441
File diff suppressed because it is too large
Load Diff
@@ -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: [],
|
||||
},
|
||||
]);
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
Generated
+1852
-87
File diff suppressed because it is too large
Load Diff
@@ -82,6 +82,9 @@
|
||||
},
|
||||
"clean:sourcemaps": {
|
||||
"cache": false
|
||||
},
|
||||
"generate-sdks": {
|
||||
"cache": false
|
||||
}
|
||||
},
|
||||
"globalDependencies": [".env"],
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user